feat(P3.1): milestone action endpoints — freeze/start/close + auto-complete hook
- New milestone_actions router with POST freeze/start/close endpoints - freeze: validates exactly 1 release maintenance task exists - start: validates all milestone/task dependencies completed, records started_at - close: allows from open/freeze/undergoing with reason - try_auto_complete_milestone helper: auto-completes milestone when sole release task finishes - Wired auto-complete into task transition and update endpoints - Added freeze enforcement: no new feature story tasks after freeze - Added started_at to milestone serializer - All actions write activity logs
This commit is contained in:
313
app/api/routers/milestone_actions.py
Normal file
313
app/api/routers/milestone_actions.py
Normal file
@@ -0,0 +1,313 @@
|
||||
"""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.
|
||||
"""
|
||||
import json
|
||||
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
|
||||
|
||||
router = APIRouter(
|
||||
prefix="/projects/{project_id}/milestones/{milestone_id}/actions",
|
||||
tags=["Milestone Actions"],
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _get_milestone_or_404(db: Session, project_id: int, milestone_id: int) -> Milestone:
|
||||
ms = (
|
||||
db.query(Milestone)
|
||||
.filter(Milestone.id == milestone_id, 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# POST /freeze
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post("/freeze", status_code=200)
|
||||
def freeze_milestone(
|
||||
project_id: int,
|
||||
milestone_id: int,
|
||||
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.
|
||||
"""
|
||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||
check_permission(db, current_user.id, project_id, "freeze milestone")
|
||||
|
||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
||||
|
||||
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 == milestone_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_id: int,
|
||||
milestone_id: int,
|
||||
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.
|
||||
"""
|
||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||
check_permission(db, current_user.id, project_id, "start milestone")
|
||||
|
||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
||||
|
||||
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 — 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:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot start: dependent milestones not completed: {incomplete}",
|
||||
)
|
||||
|
||||
# Dependency check — 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:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Cannot start: dependent tasks not completed: {incomplete_tasks}",
|
||||
)
|
||||
|
||||
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_id: int,
|
||||
milestone_id: int,
|
||||
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.
|
||||
"""
|
||||
check_project_role(db, current_user.id, project_id, min_role="mgr")
|
||||
check_permission(db, current_user.id, project_id, "close milestone")
|
||||
|
||||
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
||||
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",
|
||||
},
|
||||
)
|
||||
Reference in New Issue
Block a user