feat(P5): sync batch transition with P5.3-P5.6 guards — auth, assignee, comment, permission, deps, auto-complete
This commit is contained in:
@@ -451,17 +451,24 @@ def list_all_tags(project_id: int = None, db: Session = Depends(get_db)):
|
|||||||
|
|
||||||
# ---- Batch ----
|
# ---- Batch ----
|
||||||
|
|
||||||
class BatchTransition(BaseModel):
|
|
||||||
task_ids: List[int]
|
|
||||||
new_status: str
|
|
||||||
|
|
||||||
class BatchAssign(BaseModel):
|
class BatchAssign(BaseModel):
|
||||||
task_ids: List[int]
|
task_ids: List[int]
|
||||||
assignee_id: int
|
assignee_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class BatchTransitionBody(BaseModel):
|
||||||
|
task_ids: List[int]
|
||||||
|
new_status: str
|
||||||
|
comment: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
@router.post("/tasks/batch/transition")
|
@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]
|
valid_statuses = [s.value for s in TaskStatus]
|
||||||
if data.new_status not in valid_statuses:
|
if data.new_status not in valid_statuses:
|
||||||
raise HTTPException(status_code=400, detail="Invalid status")
|
raise HTTPException(status_code=400, detail="Invalid status")
|
||||||
@@ -469,22 +476,106 @@ def batch_transition(data: BatchTransition, bg: BackgroundTasks, db: Session = D
|
|||||||
skipped = []
|
skipped = []
|
||||||
for task_id in data.task_ids:
|
for task_id in data.task_ids:
|
||||||
task = db.query(Task).filter(Task.id == task_id).first()
|
task = db.query(Task).filter(Task.id == task_id).first()
|
||||||
if task:
|
if not task:
|
||||||
old_status = task.status.value if hasattr(task.status, 'value') else task.status
|
skipped.append({"id": task_id, "title": None, "old": None,
|
||||||
allowed = VALID_TRANSITIONS.get(old_status, set())
|
"reason": "Task not found"})
|
||||||
if data.new_status not in allowed:
|
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,
|
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
|
continue
|
||||||
if data.new_status == "undergoing" and not task.started_on:
|
|
||||||
task.started_on = datetime.utcnow()
|
# P5.3: open → undergoing requires assignee == current_user
|
||||||
if data.new_status in ("closed", "completed") and not task.finished_on:
|
if old_status == "open" and data.new_status == "undergoing":
|
||||||
task.finished_on = datetime.utcnow()
|
if not task.assignee_id:
|
||||||
if data.new_status == "open" and old_status in ("completed", "closed"):
|
skipped.append({"id": task.id, "title": task.title, "old": old_status,
|
||||||
task.finished_on = None
|
"reason": "Assignee must be set before starting"})
|
||||||
task.status = data.new_status
|
continue
|
||||||
updated.append({"id": task.id, "title": task.title, "old": old_status, "new": data.new_status})
|
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()
|
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:
|
for u in updated:
|
||||||
event = "task.closed" if data.new_status == "closed" else "task.updated"
|
event = "task.closed" if data.new_status == "closed" else "task.updated"
|
||||||
bg.add_task(fire_webhooks_sync, event, u, None, db)
|
bg.add_task(fire_webhooks_sync, event, u, None, db)
|
||||||
|
|||||||
Reference in New Issue
Block a user