From 7d8c448cb82eecf265718bf95e71978f6a9e0073 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 17 Mar 2026 04:03:05 +0000 Subject: [PATCH] =?UTF-8?q?feat(P3.1):=20milestone=20action=20endpoints=20?= =?UTF-8?q?=E2=80=94=20freeze/start/close=20+=20auto-complete=20hook?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- app/api/routers/milestone_actions.py | 313 +++++++++++++++++++++++++++ app/api/routers/milestones.py | 11 +- app/api/routers/tasks.py | 12 + app/main.py | 2 + 4 files changed, 336 insertions(+), 2 deletions(-) create mode 100644 app/api/routers/milestone_actions.py diff --git a/app/api/routers/milestone_actions.py b/app/api/routers/milestone_actions.py new file mode 100644 index 0000000..f3fcaa1 --- /dev/null +++ b/app/api/routers/milestone_actions.py @@ -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", + }, + ) diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index 18670b9..a5a5e72 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -31,6 +31,7 @@ def _serialize_milestone(milestone): "depend_on_tasks": json.loads(milestone.depend_on_tasks) if milestone.depend_on_tasks else [], "project_id": milestone.project_id, "created_by_id": milestone.created_by_id, + "started_at": milestone.started_at, "created_at": milestone.created_at, "updated_at": milestone.updated_at, } @@ -111,8 +112,14 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas if not milestone: raise HTTPException(status_code=404, detail="Milestone not found") - if milestone.status and hasattr(milestone.status, 'value') and milestone.status.value == "undergoing": - raise HTTPException(status_code=400, detail="Cannot add items to a milestone that is undergoing") + ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status + if ms_status in ("undergoing", "completed", "closed"): + raise HTTPException(status_code=400, detail=f"Cannot add items to a milestone that is '{ms_status}'") + # P3.6 / §5: freeze prevents adding new feature story tasks + task_type = task_data.model_dump(exclude_unset=True).get("task_type", "") + task_subtype = task_data.model_dump(exclude_unset=True).get("task_subtype", "") + if ms_status == "freeze" and task_type == "story" and task_subtype == "feature": + raise HTTPException(status_code=400, detail="Cannot add feature story tasks after milestone is frozen") # Generate task_code milestone_code = milestone.milestone_code or f"m{milestone.id}" diff --git a/app/api/routers/tasks.py b/app/api/routers/tasks.py index 28940e2..cc7ea46 100644 --- a/app/api/routers/tasks.py +++ b/app/api/routers/tasks.py @@ -176,6 +176,12 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep setattr(task, field, value) db.commit() db.refresh(task) + + # P3.5: auto-complete milestone when release task reaches completed via update + if "status" in update_data and update_data["status"] == "completed": + from app.api.routers.milestone_actions import try_auto_complete_milestone + try_auto_complete_milestone(db, task, user_id=current_user.id) + return task @@ -209,6 +215,12 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess task.status = new_status db.commit() db.refresh(task) + + # P3.5: auto-complete milestone when its sole release task is completed + if new_status == "completed": + from app.api.routers.milestone_actions import try_auto_complete_milestone + try_auto_complete_milestone(db, task, user_id=None) + event = "task.closed" if new_status == "closed" else "task.updated" bg.add_task(fire_webhooks_sync, event, {"task_id": task.id, "title": task.title, "old_status": old_status, "new_status": new_status}, diff --git a/app/main.py b/app/main.py index 3011d47..73d4807 100644 --- a/app/main.py +++ b/app/main.py @@ -38,6 +38,7 @@ from app.api.routers.monitor import router as monitor_router from app.api.routers.milestones import router as milestones_router from app.api.routers.roles import router as roles_router from app.api.routers.proposes import router as proposes_router +from app.api.routers.milestone_actions import router as milestone_actions_router app.include_router(auth_router) app.include_router(tasks_router) @@ -50,6 +51,7 @@ app.include_router(monitor_router) app.include_router(milestones_router) app.include_router(roles_router) app.include_router(proposes_router) +app.include_router(milestone_actions_router) # Auto schema migration for lightweight deployments