From 7a16639aacbdc5355e067ab1c85b444bd8faf08f Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 17 Mar 2026 10:04:17 +0000 Subject: [PATCH] feat(P8.3): milestone preflight endpoint for freeze/start button pre-condition checks --- app/api/routers/milestone_actions.py | 84 ++++++++++++++++++++++++++++ 1 file changed, 84 insertions(+) diff --git a/app/api/routers/milestone_actions.py b/app/api/routers/milestone_actions.py index f3fcaa1..a307f6e 100644 --- a/app/api/routers/milestone_actions.py +++ b/app/api/routers/milestone_actions.py @@ -53,6 +53,90 @@ class CloseBody(BaseModel): reason: Optional[str] = None +# --------------------------------------------------------------------------- +# GET /preflight — lightweight pre-condition check for UI button states +# --------------------------------------------------------------------------- + +@router.get("/preflight", status_code=200) +def preflight_milestone_actions( + project_id: int, + milestone_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user_or_apikey), +): + """Return pre-condition check results for freeze / start actions. + + The frontend uses this to decide whether to *disable* buttons and what + hint text to show. This endpoint never mutates data. + """ + check_project_role(db, current_user.id, project_id, min_role="viewer") + ms = _get_milestone_or_404(db, project_id, milestone_id) + ms_status = _ms_status_value(ms) + + result: dict = {"status": ms_status, "freeze": None, "start": None} + + # --- freeze pre-check (only meaningful when status == open) --- + if ms_status == "open": + release_tasks = ( + db.query(Task) + .filter( + Task.milestone_id == milestone_id, + Task.task_type == "maintenance", + Task.task_subtype == "release", + ) + .all() + ) + if len(release_tasks) == 0: + result["freeze"] = { + "allowed": False, + "reason": "No maintenance/release task found. Create one before freezing.", + } + elif len(release_tasks) > 1: + result["freeze"] = { + "allowed": False, + "reason": f"Found {len(release_tasks)} maintenance/release tasks — expected exactly 1.", + } + else: + result["freeze"] = {"allowed": True, "reason": None} + + # --- start pre-check (only meaningful when status == freeze) --- + if ms_status == "freeze": + blockers: list[str] = [] + + # milestone dependencies + dep_ms_ids = [] + if ms.depend_on_milestones: + try: + dep_ms_ids = json.loads(ms.depend_on_milestones) + except (json.JSONDecodeError, TypeError): + dep_ms_ids = [] + if dep_ms_ids: + dep_milestones = db.query(Milestone).filter(Milestone.id.in_(dep_ms_ids)).all() + incomplete = [m.id for m in dep_milestones if _ms_status_value(m) != "completed"] + if incomplete: + blockers.append(f"Dependent milestones not completed: {incomplete}") + + # task dependencies + dep_task_ids = [] + if ms.depend_on_tasks: + try: + dep_task_ids = json.loads(ms.depend_on_tasks) + except (json.JSONDecodeError, TypeError): + dep_task_ids = [] + if dep_task_ids: + dep_tasks = db.query(Task).filter(Task.id.in_(dep_task_ids)).all() + incomplete_tasks = [t.id for t in dep_tasks if (t.status.value if hasattr(t.status, "value") else t.status) != "completed"] + if incomplete_tasks: + blockers.append(f"Dependent tasks not completed: {incomplete_tasks}") + + if blockers: + result["start"] = {"allowed": False, "reason": "; ".join(blockers)} + else: + result["start"] = {"allowed": True, "reason": None} + + return result + + # --------------------------------------------------------------------------- # POST /freeze # ---------------------------------------------------------------------------