diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index e5c5df7..a250e6a 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -16,6 +16,7 @@ from app.models.notification import Notification as NotificationModel from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role, check_permission, ensure_can_edit_task from app.services.activity import log_activity +from app.services.dependency_check import check_task_deps router = APIRouter(tags=["Tasks"]) @@ -293,7 +294,7 @@ def transition_task( # P5.1: enforce state-machine _check_transition(old_status, new_status) - # P5.2: pending -> open requires milestone to be undergoing (dependencies checked later) + # P5.2: pending -> open requires milestone to be undergoing + task deps satisfied if old_status == "pending" and new_status == "open": milestone = db.query(Milestone).filter(Milestone.id == task.milestone_id).first() if milestone: @@ -303,6 +304,13 @@ def transition_task( status_code=400, detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'", ) + # P4.3: check task-level depend_on + dep_result = check_task_deps(db, task.depend_on) + if not dep_result.ok: + raise HTTPException( + status_code=400, + detail=f"Cannot open task: {dep_result.reason}", + ) # P5.3: open -> undergoing requires assignee AND operator must be the assignee if old_status == "open" and new_status == "undergoing": diff --git a/app/services/dependency_check.py b/app/services/dependency_check.py index 690065c..f7f81cb 100644 --- a/app/services/dependency_check.py +++ b/app/services/dependency_check.py @@ -111,3 +111,38 @@ def check_milestone_deps( result.blockers.append(f"Dependent tasks not {required_status}: {incomplete_tasks}") return result + + +def check_task_deps( + db: Session, + depend_on: str | None, + *, + required_status: str = "completed", +) -> DepCheckResult: + """Check whether a task's depend_on tasks are all satisfied. + + Parameters + ---------- + db: + Active DB session. + depend_on: + JSON-encoded list of task IDs (from the task's ``depend_on`` field). + required_status: + The status dependees must have reached (default ``"completed"``). + + Returns + ------- + DepCheckResult with ``ok=True`` if all deps satisfied, else ``ok=False`` + with human-readable ``blockers``. + """ + result = DepCheckResult() + task_ids = _parse_json_ids(depend_on) + if task_ids: + dep_tasks: Sequence[Task] = ( + db.query(Task).filter(Task.id.in_(task_ids)).all() + ) + incomplete = [t.id for t in dep_tasks if _task_status(t) != required_status] + if incomplete: + result.ok = False + result.blockers.append(f"Dependent tasks not {required_status}: {incomplete}") + return result