Files
HarborForge.Backend/app/api/routers/milestone_actions.py
zhi 3afbbc2a88 feat(P2.1): register 9 new permissions (milestone/task/propose actions) + wire check_permission in all action endpoints
- 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
2026-03-17 15:03:48 +00:00

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