- Add milestone.freeze/start/close, task.close/reopen_closed/reopen_completed, propose.accept/reject/reopen to DEFAULT_PERMISSIONS - Replace placeholder check_project_role with check_permission in proposes.py accept/reject/reopen - Replace freeform permission strings with dotted names in milestone_actions.py - Add task.close and task.reopen_* permission checks in tasks.py transition endpoint - Admin role auto-inherits all new permissions via init_wizard
398 lines
13 KiB
Python
398 lines
13 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.
|
|
"""
|
|
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
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# GET /preflight — lightweight pre-condition check for UI button states
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@router.get("/preflight", status_code=200)
|
|
def preflight_milestone_actions(
|
|
project_id: int,
|
|
milestone_id: int,
|
|
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.
|
|
"""
|
|
check_project_role(db, current_user.id, project_id, min_role="viewer")
|
|
ms = _get_milestone_or_404(db, project_id, milestone_id)
|
|
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 == milestone_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":
|
|
blockers: list[str] = []
|
|
|
|
# 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:
|
|
blockers.append(f"Dependent milestones not completed: {incomplete}")
|
|
|
|
# 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:
|
|
blockers.append(f"Dependent tasks not completed: {incomplete_tasks}")
|
|
|
|
if blockers:
|
|
result["start"] = {"allowed": False, "reason": "; ".join(blockers)}
|
|
else:
|
|
result["start"] = {"allowed": True, "reason": None}
|
|
|
|
return result
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# 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, "milestone.freeze")
|
|
|
|
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, "milestone.start")
|
|
|
|
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, "milestone.close")
|
|
|
|
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",
|
|
},
|
|
)
|