Files
HarborForge.Backend/app/api/routers/milestone_actions.py

349 lines
12 KiB
Python

"""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",
},
)