feat(P5.3+P5.4): enforce assignee identity on start/complete + require completion comment in transition endpoint

This commit is contained in:
zhi
2026-03-17 11:02:19 +00:00
parent 7a16639aac
commit ffb0fa6058

View File

@@ -1,6 +1,6 @@
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table.""" """Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
import math import math
from typing import List from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -223,8 +223,19 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
# ---- Transition ---- # ---- Transition ----
class TransitionBody(BaseModel):
comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) @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] valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses: if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {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'", 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 old_status == "open" and new_status == "undergoing":
if not task.assignee_id: if not task.assignee_id:
raise HTTPException(status_code=400, detail="Cannot start task: assignee must be set first") 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 # P5.6: reopen from completed/closed -> open
if new_status == "open" and old_status in ("completed", "closed"): 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.commit()
db.refresh(task) 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 # P3.5: auto-complete milestone when its sole release task is completed
if new_status == "completed": if new_status == "completed":
from app.api.routers.milestone_actions import try_auto_complete_milestone 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" event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,