feat(P8.3): milestone preflight endpoint for freeze/start button pre-condition checks

This commit is contained in:
zhi
2026-03-17 10:04:17 +00:00
parent 314040cef5
commit 7a16639aac

View File

@@ -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
# ---------------------------------------------------------------------------