feat(P5.3+P5.4): enforce assignee identity on start/complete + require completion comment in transition endpoint
This commit is contained in:
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user