From ffb0fa6058520dcca470011405fa1a0bacd11d92 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 17 Mar 2026 11:02:19 +0000 Subject: [PATCH] feat(P5.3+P5.4): enforce assignee identity on start/complete + require completion comment in transition endpoint --- app/api/routers/tasks.py | 44 ++++++++++++++++++++++++++++++++++++---- 1 file changed, 40 insertions(+), 4 deletions(-) diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index f4e7c47..33cb8c8 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -1,6 +1,6 @@ """Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table.""" import math -from typing import List +from typing import List, Optional from datetime import datetime from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from sqlalchemy.orm import Session @@ -223,8 +223,19 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model # ---- Transition ---- +class TransitionBody(BaseModel): + comment: Optional[str] = None + + @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) -def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): +def transition_task( + task_id: int, + new_status: str, + bg: BackgroundTasks, + body: TransitionBody = None, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): valid_statuses = [s.value for s in TaskStatus] if new_status not in valid_statuses: raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") @@ -247,10 +258,21 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'", ) - # P5.3: open -> undergoing requires assignee + # P5.3: open -> undergoing requires assignee AND operator must be the assignee if old_status == "open" and new_status == "undergoing": if not task.assignee_id: raise HTTPException(status_code=400, detail="Cannot start task: assignee must be set first") + if current_user.id != task.assignee_id: + raise HTTPException(status_code=403, detail="Only the assigned user can start this task") + + # P5.4: undergoing -> completed requires a completion comment + if old_status == "undergoing" and new_status == "completed": + comment_text = body.comment if body else None + if not comment_text or not comment_text.strip(): + raise HTTPException(status_code=400, detail="A completion comment is required when finishing a task") + # P5.4: also only the assignee can complete + if task.assignee_id and current_user.id != task.assignee_id: + raise HTTPException(status_code=403, detail="Only the assigned user can complete this task") # P5.6: reopen from completed/closed -> open if new_status == "open" and old_status in ("completed", "closed"): @@ -265,10 +287,24 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess db.commit() db.refresh(task) + # P5.4: auto-create completion comment + if old_status == "undergoing" and new_status == "completed" and body and body.comment: + db_comment = models.Comment( + content=body.comment.strip(), + task_id=task.id, + author_id=current_user.id, + ) + db.add(db_comment) + db.commit() + + # Log the transition activity + log_activity(db, f"task.transition.{new_status}", "task", task.id, current_user.id, + {"old_status": old_status, "new_status": new_status}) + # P3.5: auto-complete milestone when its sole release task is completed if new_status == "completed": from app.api.routers.milestone_actions import try_auto_complete_milestone - try_auto_complete_milestone(db, task, user_id=None) + try_auto_complete_milestone(db, task, user_id=current_user.id) event = "task.closed" if new_status == "closed" else "task.updated" bg.add_task(fire_webhooks_sync, event,