From 7bad57eb0e381fbecbb7f3e3e5b75e2e9f3488a4 Mon Sep 17 00:00:00 2001 From: zhi Date: Wed, 18 Mar 2026 01:01:59 +0000 Subject: [PATCH] =?UTF-8?q?feat(P5):=20sync=20batch=20transition=20with=20?= =?UTF-8?q?P5.3-P5.6=20guards=20=E2=80=94=20auth,=20assignee,=20comment,?= =?UTF-8?q?=20permission,=20deps,=20auto-complete?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routers/tasks.py | 127 +++++++++++++++++++++++++++++++++------ 1 file changed, 109 insertions(+), 18 deletions(-) diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index d07e17d..f29c75a 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -451,17 +451,24 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)): # ---- Batch ---- -class BatchTransition(BaseModel): - task_ids: List[int] - new_status: str - class BatchAssign(BaseModel): task_ids: List[int] assignee_id: int +class BatchTransitionBody(BaseModel): + task_ids: List[int] + new_status: str + comment: Optional[str] = None + + @router.post("/tasks/batch/transition") -def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = Depends(get_db)): +def batch_transition( + data: BatchTransitionBody, + bg: BackgroundTasks, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): valid_statuses = [s.value for s in TaskStatus] if data.new_status not in valid_statuses: raise HTTPException(status_code=400, detail="Invalid status") @@ -469,22 +476,106 @@ def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = D skipped = [] for task_id in data.task_ids: task = db.query(Task).filter(Task.id == task_id).first() - if task: - old_status = task.status.value if hasattr(task.status, 'value') else task.status - allowed = VALID_TRANSITIONS.get(old_status, set()) - if data.new_status not in allowed: + if not task: + skipped.append({"id": task_id, "title": None, "old": None, + "reason": "Task not found"}) + continue + old_status = task.status.value if hasattr(task.status, 'value') else task.status + # P5.1: state-machine check + allowed = VALID_TRANSITIONS.get(old_status, set()) + if data.new_status not in allowed: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"}) + continue + + # P5.2: pending → open requires milestone undergoing + task deps + if old_status == "pending" and data.new_status == "open": + milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first() + if milestone: + ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status + if ms_status != "undergoing": + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": f"Milestone is '{ms_status}', must be 'undergoing'"}) + continue + dep_result = check_task_deps(db, task.depend_on) + if not dep_result.ok: skipped.append({"id": task.id, "title": task.title, "old": old_status, - "reason": f"Cannot transition from '{old_status}' to '{data.new_status}'"}) + "reason": dep_result.reason}) continue - if data.new_status == "undergoing" and not task.started_on: - task.started_on = datetime.utcnow() - if data.new_status in ("closed", "completed") and not task.finished_on: - task.finished_on = datetime.utcnow() - if data.new_status == "open" and old_status in ("completed", "closed"): - task.finished_on = None - task.status = data.new_status - updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status}) + + # P5.3: open → undergoing requires assignee == current_user + if old_status == "open" and data.new_status == "undergoing": + if not task.assignee_id: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": "Assignee must be set before starting"}) + continue + if current_user.id != task.assignee_id: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": "Only the assigned user can start this task"}) + continue + + # P5.4: undergoing → completed requires comment + assignee check + if old_status == "undergoing" and data.new_status == "completed": + comment_text = data.comment + if not comment_text or not comment_text.strip(): + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": "A completion comment is required"}) + continue + if task.assignee_id and current_user.id != task.assignee_id: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": "Only the assigned user can complete this task"}) + continue + + # P5.5: close requires permission + if data.new_status == "closed": + try: + check_permission(db, current_user.id, task.project_id, "task.close") + except HTTPException: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": "Missing 'task.close' permission"}) + continue + + # P5.6: reopen requires permission + if data.new_status == "open" and old_status in ("completed", "closed"): + perm = "task.reopen_completed" if old_status == "completed" else "task.reopen_closed" + try: + check_permission(db, current_user.id, task.project_id, perm) + except HTTPException: + skipped.append({"id": task.id, "title": task.title, "old": old_status, + "reason": f"Missing '{perm}' permission"}) + continue + task.finished_on = None + + if data.new_status == "undergoing" and not task.started_on: + task.started_on = datetime.utcnow() + if data.new_status in ("closed", "completed") and not task.finished_on: + task.finished_on = datetime.utcnow() + task.status = data.new_status + updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status}) + + # Activity log per task + log_activity(db, f"task.transition.{data.new_status}", "task", task.id, current_user.id, + {"old_status": old_status, "new_status": data.new_status}) + + # P5.4: auto-create completion comment + if old_status == "undergoing" and data.new_status == "completed" and data.comment: + db_comment = models.Comment( + content=data.comment.strip(), + task_id=task.id, + author_id=current_user.id, + ) + db.add(db_comment) + db.commit() + + # P3.5: auto-complete milestone for any completed task + for u in updated: + if u["new"] == "completed": + t = db.query(Task).filter(Task.id == u["id"]).first() + if t: + from app.api.routers.milestone_actions import try_auto_complete_milestone + try_auto_complete_milestone(db, t, user_id=current_user.id) + for u in updated: event = "task.closed" if data.new_status == "closed" else "task.updated" bg.add_task(fire_webhooks_sync, event, u, None, db)