"""Milestone status-machine action endpoints (P3.1). Provides freeze / start / close actions on milestones. completed is triggered automatically when the sole release maintenance task finishes. """ from datetime import datetime, timezone from typing import Optional from fastapi import APIRouter, Depends, HTTPException, status from pydantic import BaseModel from sqlalchemy.orm import Session from app.core.config import get_db from app.api.deps import get_current_user_or_apikey from app.api.rbac import check_project_role, check_permission from app.models import models from app.models.milestone import Milestone, MilestoneStatus from app.models.task import Task, TaskStatus from app.services.activity import log_activity from app.services.dependency_check import check_milestone_deps router = APIRouter( prefix="/projects/{project_code}/milestones/{milestone_code}/actions", tags=["Milestone Actions"], ) # --------------------------------------------------------------------------- # Helpers # --------------------------------------------------------------------------- def _resolve_project_or_404(db: Session, project_code: str): project = db.query(models.Project).filter(models.Project.project_code == project_code).first() if not project: raise HTTPException(status_code=404, detail="Project not found") return project def _get_milestone_or_404(db: Session, project_code: str, milestone_code: str) -> Milestone: project = _resolve_project_or_404(db, project_code) ms = ( db.query(Milestone) .filter(Milestone.milestone_code == milestone_code, Milestone.project_id == project.id) .first() ) if not ms: raise HTTPException(status_code=404, detail="Milestone not found") return ms def _ms_status_value(ms: Milestone) -> str: """Return status as plain string regardless of enum wrapper.""" return ms.status.value if hasattr(ms.status, "value") else ms.status # --------------------------------------------------------------------------- # Request bodies # --------------------------------------------------------------------------- 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_code: str, milestone_code: str, 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. """ project = _resolve_project_or_404(db, project_code) check_project_role(db, current_user.id, project.id, min_role="viewer") ms = _get_milestone_or_404(db, project_code, milestone_code) 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 == ms.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": dep_result = check_milestone_deps( db, ms.depend_on_milestones, ms.depend_on_tasks, ) if dep_result.ok: result["start"] = {"allowed": True, "reason": None} else: result["start"] = {"allowed": False, "reason": dep_result.reason} return result # --------------------------------------------------------------------------- # POST /freeze # --------------------------------------------------------------------------- @router.post("/freeze", status_code=200) def freeze_milestone( project_code: str, milestone_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Freeze a milestone (open → freeze). Pre-conditions: - Milestone must be in ``open`` status. - Milestone must have **exactly one** maintenance task with subtype ``release``. - Caller must have ``freeze milestone`` permission. """ project = _resolve_project_or_404(db, project_code) check_project_role(db, current_user.id, project.id, min_role="mgr") check_permission(db, current_user.id, project.id, "milestone.freeze") ms = _get_milestone_or_404(db, project_code, milestone_code) if _ms_status_value(ms) != "open": raise HTTPException( status_code=400, detail=f"Cannot freeze: milestone is '{_ms_status_value(ms)}', expected 'open'", ) # Check: exactly one maintenance/release task release_tasks = ( db.query(Task) .filter( Task.milestone_id == ms.id, Task.task_type == "maintenance", Task.task_subtype == "release", ) .all() ) if len(release_tasks) == 0: raise HTTPException( status_code=400, detail="Cannot freeze: milestone has no maintenance/release task. Create one first.", ) if len(release_tasks) > 1: raise HTTPException( status_code=400, detail=f"Cannot freeze: milestone has {len(release_tasks)} maintenance/release tasks, expected exactly 1.", ) ms.status = MilestoneStatus.FREEZE db.commit() db.refresh(ms) log_activity( db, action="freeze", entity_type="milestone", entity_id=ms.id, user_id=current_user.id, details={"from": "open", "to": "freeze"}, ) return {"detail": "Milestone frozen", "status": "freeze"} # --------------------------------------------------------------------------- # POST /start # --------------------------------------------------------------------------- @router.post("/start", status_code=200) def start_milestone( project_code: str, milestone_code: str, db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Start a milestone (freeze → undergoing). Pre-conditions: - Milestone must be in ``freeze`` status. - All milestone dependencies must be completed. - Caller must have ``start milestone`` permission. """ project = _resolve_project_or_404(db, project_code) check_project_role(db, current_user.id, project.id, min_role="mgr") check_permission(db, current_user.id, project.id, "milestone.start") ms = _get_milestone_or_404(db, project_code, milestone_code) if _ms_status_value(ms) != "freeze": raise HTTPException( status_code=400, detail=f"Cannot start: milestone is '{_ms_status_value(ms)}', expected 'freeze'", ) # Dependency check (P4.1 — shared helper) dep_result = check_milestone_deps( db, ms.depend_on_milestones, ms.depend_on_tasks, ) if not dep_result.ok: raise HTTPException( status_code=400, detail=f"Cannot start: {dep_result.reason}", ) ms.status = MilestoneStatus.UNDERGOING ms.started_at = datetime.now(timezone.utc) db.commit() db.refresh(ms) log_activity( db, action="start", entity_type="milestone", entity_id=ms.id, user_id=current_user.id, details={"from": "freeze", "to": "undergoing", "started_at": ms.started_at.isoformat()}, ) return {"detail": "Milestone started", "status": "undergoing", "started_at": ms.started_at.isoformat()} # --------------------------------------------------------------------------- # POST /close # --------------------------------------------------------------------------- @router.post("/close", status_code=200) def close_milestone( project_code: str, milestone_code: str, body: CloseBody = CloseBody(), db: Session = Depends(get_db), current_user: models.User = Depends(get_current_user_or_apikey), ): """Close (abandon) a milestone (open/freeze/undergoing → closed). Pre-conditions: - Milestone must be in ``open``, ``freeze``, or ``undergoing`` status. - Caller must have ``close milestone`` permission. """ project = _resolve_project_or_404(db, project_code) check_project_role(db, current_user.id, project.id, min_role="mgr") check_permission(db, current_user.id, project.id, "milestone.close") ms = _get_milestone_or_404(db, project_code, milestone_code) current = _ms_status_value(ms) allowed_from = {"open", "freeze", "undergoing"} if current not in allowed_from: raise HTTPException( status_code=400, detail=f"Cannot close: milestone is '{current}', must be one of {sorted(allowed_from)}", ) ms.status = MilestoneStatus.CLOSED db.commit() db.refresh(ms) log_activity( db, action="close", entity_type="milestone", entity_id=ms.id, user_id=current_user.id, details={"from": current, "to": "closed", "reason": body.reason}, ) return {"detail": "Milestone closed", "status": "closed"} # --------------------------------------------------------------------------- # Auto-complete helper (called from task completion logic) # --------------------------------------------------------------------------- def try_auto_complete_milestone(db: Session, task: Task, user_id: int | None = None): """Check if a just-completed task is the sole release/maintenance task of its milestone, and if so auto-complete the milestone. This function is designed to be called from the task status transition logic whenever a task reaches ``completed``. """ if task.task_type != "maintenance" or task.task_subtype != "release": return # not a release task — nothing to do milestone = ( db.query(Milestone) .filter(Milestone.id == task.milestone_id) .first() ) if not milestone: return if _ms_status_value(milestone) != "undergoing": return # only auto-complete from undergoing # Verify this is the *only* release task under the milestone release_count = ( db.query(Task) .filter( Task.milestone_id == milestone.id, Task.task_type == "maintenance", Task.task_subtype == "release", ) .count() ) if release_count != 1: return # ambiguous — don't auto-complete milestone.status = MilestoneStatus.COMPLETED db.commit() log_activity( db, action="auto_complete", entity_type="milestone", entity_id=milestone.id, user_id=user_id, details={ "trigger": f"release task #{task.id} completed", "from": "undergoing", "to": "completed", }, )