feat: time tracking / work logs (create, list, summary, project summary, CLI commands)

This commit is contained in:
Zhi
2026-02-23 05:11:52 +00:00
parent 9f276464b2
commit 703103af91
4 changed files with 157 additions and 0 deletions

View File

@@ -269,6 +269,7 @@ def startup():
from app.models import activity
from app.models import milestone
from app.models import notification
from app.models import worklog
Base.metadata.create_all(bind=engine)
@@ -1014,3 +1015,99 @@ def assign_issue(issue_id: int, assignee_id: int, db: Session = Depends(get_db))
"issue", issue.id)
return {"issue_id": issue.id, "assignee_id": assignee_id, "title": issue.title}
# ============ Work Logs / Time Tracking ============
from app.models.worklog import WorkLog
class WorkLogCreate(PydanticBaseModel):
issue_id: int
user_id: int
hours: float
description: str | None = None
logged_date: datetime
class WorkLogResponse(PydanticBaseModel):
id: int
issue_id: int
user_id: int
hours: float
description: str | None = None
logged_date: datetime
created_at: datetime
class Config:
from_attributes = True
@app.post("/worklogs", response_model=WorkLogResponse, status_code=status.HTTP_201_CREATED)
def create_worklog(wl: WorkLogCreate, db: Session = Depends(get_db)):
"""Log time spent on an issue."""
issue = db.query(models.Issue).filter(models.Issue.id == wl.issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
user = db.query(models.User).filter(models.User.id == wl.user_id).first()
if not user:
raise HTTPException(status_code=404, detail="User not found")
if wl.hours <= 0:
raise HTTPException(status_code=400, detail="Hours must be positive")
db_wl = WorkLog(**wl.model_dump())
db.add(db_wl)
db.commit()
db.refresh(db_wl)
return db_wl
@app.get("/issues/{issue_id}/worklogs", response_model=List[WorkLogResponse])
def list_issue_worklogs(issue_id: int, db: Session = Depends(get_db)):
"""List all work logs for an issue."""
return db.query(WorkLog).filter(WorkLog.issue_id == issue_id).order_by(WorkLog.logged_date.desc()).all()
@app.get("/issues/{issue_id}/worklogs/summary")
def issue_worklog_summary(issue_id: int, db: Session = Depends(get_db)):
"""Get total hours logged on an issue."""
issue = db.query(models.Issue).filter(models.Issue.id == issue_id).first()
if not issue:
raise HTTPException(status_code=404, detail="Issue not found")
from sqlalchemy import func as sqlfunc
total = db.query(sqlfunc.sum(WorkLog.hours)).filter(WorkLog.issue_id == issue_id).scalar() or 0
count = db.query(WorkLog).filter(WorkLog.issue_id == issue_id).count()
return {"issue_id": issue_id, "total_hours": round(total, 2), "log_count": count}
@app.get("/users/{user_id}/worklogs", response_model=List[WorkLogResponse])
def list_user_worklogs(user_id: int, limit: int = 50, db: Session = Depends(get_db)):
"""List work logs by user."""
return db.query(WorkLog).filter(WorkLog.user_id == user_id).order_by(WorkLog.logged_date.desc()).limit(limit).all()
@app.delete("/worklogs/{worklog_id}", status_code=status.HTTP_204_NO_CONTENT)
def delete_worklog(worklog_id: int, db: Session = Depends(get_db)):
wl = db.query(WorkLog).filter(WorkLog.id == worklog_id).first()
if not wl:
raise HTTPException(status_code=404, detail="Work log not found")
db.delete(wl)
db.commit()
return None
@app.get("/projects/{project_id}/worklogs/summary")
def project_worklog_summary(project_id: int, db: Session = Depends(get_db)):
"""Get time tracking summary for a project."""
from sqlalchemy import func as sqlfunc
results = db.query(
models.User.id,
models.User.username,
sqlfunc.sum(WorkLog.hours).label("total_hours"),
sqlfunc.count(WorkLog.id).label("log_count")
).join(WorkLog, WorkLog.user_id == models.User.id)\
.join(models.Issue, WorkLog.issue_id == models.Issue.id)\
.filter(models.Issue.project_id == project_id)\
.group_by(models.User.id, models.User.username).all()
total = sum(r.total_hours for r in results)
by_user = [{"user_id": r.id, "username": r.username, "hours": round(r.total_hours, 2), "logs": r.log_count} for r in results]
return {"project_id": project_id, "total_hours": round(total, 2), "by_user": by_user}