Compare commits
1 Commits
75ccbcb362
...
7d8c448cb8
| Author | SHA1 | Date | |
|---|---|---|---|
| 7d8c448cb8 |
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",
|
||||
},
|
||||
)
|
||||
@@ -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}"
|
||||
|
||||
@@ -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},
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user