Compare commits

...

4 Commits

3 changed files with 177 additions and 8 deletions

View File

@@ -53,6 +53,90 @@ class CloseBody(BaseModel):
reason: Optional[str] = None 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 # POST /freeze
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -147,9 +147,12 @@ def create_milestone_task(project_id: int, milestone_id: int, task_data: schemas
ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status ms_status = milestone.status.value if hasattr(milestone.status, 'value') else milestone.status
if ms_status in ("undergoing", "completed", "closed"): if ms_status in ("undergoing", "completed", "closed"):
raise HTTPException(status_code=400, detail=f"Cannot add items to a milestone that is '{ms_status}'") 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 # P9.6: feature story tasks must come from propose accept, not direct creation
task_type = task_data.model_dump(exclude_unset=True).get("task_type", "") task_type = task_data.model_dump(exclude_unset=True).get("task_type", "")
task_subtype = task_data.model_dump(exclude_unset=True).get("task_subtype", "") task_subtype = task_data.model_dump(exclude_unset=True).get("task_subtype", "")
if task_type == "story" and task_subtype == "feature":
raise HTTPException(status_code=400, detail="Feature story tasks can only be created via propose accept, not direct creation")
# P3.6 / §5: freeze prevents adding new feature story tasks (redundant after P9.6 but kept as defense-in-depth)
if ms_status == "freeze" and task_type == "story" and task_subtype == "feature": 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") raise HTTPException(status_code=400, detail="Cannot add feature story tasks after milestone is frozen")

View File

@@ -1,6 +1,6 @@
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table.""" """Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
import math import math
from typing import List from typing import List, Optional
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
@@ -52,7 +52,17 @@ TASK_SUBTYPE_MAP = {
ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys()) ALLOWED_TASK_TYPES = set(TASK_SUBTYPE_MAP.keys())
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None): """P9.6 — type+subtype combos that may NOT be created via general create endpoints.
feature story → must come from propose accept
release maintenance → must come from controlled milestone/release flow
"""
RESTRICTED_TYPE_SUBTYPES = {
("story", "feature"),
("maintenance", "release"),
}
def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None, *, allow_restricted: bool = False):
if task_type is None: if task_type is None:
return return
if task_type not in ALLOWED_TASK_TYPES: if task_type not in ALLOWED_TASK_TYPES:
@@ -60,6 +70,13 @@ def _validate_task_type_subtype(task_type: str | None, task_subtype: str | None)
allowed = TASK_SUBTYPE_MAP.get(task_type, set()) allowed = TASK_SUBTYPE_MAP.get(task_type, set())
if task_subtype and task_subtype not in allowed: if task_subtype and task_subtype not in allowed:
raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}') raise HTTPException(status_code=400, detail=f'Invalid task_subtype for {task_type}: {task_subtype}')
# P9.6: block restricted combos unless explicitly allowed (e.g. propose accept, internal create)
if not allow_restricted and (task_type, task_subtype) in RESTRICTED_TYPE_SUBTYPES:
raise HTTPException(
status_code=400,
detail=f"Cannot create {task_type}/{task_subtype} task via general create. "
f"Use the appropriate workflow (propose accept / milestone release setup)."
)
def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None): def _notify_user(db, user_id, ntype, title, message=None, entity_type=None, entity_id=None):
@@ -181,9 +198,38 @@ def update_task(task_id: int, task_update: schemas.TaskUpdate, db: Session = Dep
task = db.query(Task).filter(Task.id == task_id).first() task = db.query(Task).filter(Task.id == task_id).first()
if not task: if not task:
raise HTTPException(status_code=404, detail="Task not found") raise HTTPException(status_code=404, detail="Task not found")
ensure_can_edit_task(db, current_user.id, task)
# P5.7: status-based edit restrictions
current_status = task.status.value if hasattr(task.status, 'value') else task.status
update_data = task_update.model_dump(exclude_unset=True) update_data = task_update.model_dump(exclude_unset=True)
# Fields that are always allowed regardless of status (non-body edits)
_always_allowed = {"status"}
body_fields = {k for k in update_data.keys() if k not in _always_allowed}
if body_fields:
# undergoing/completed/closed: body edits forbidden
if current_status in ("undergoing", "completed", "closed"):
raise HTTPException(
status_code=400,
detail=f"Cannot edit task body fields in '{current_status}' status. "
f"Blocked fields: {sorted(body_fields)}",
)
# open + assignee set: only assignee or admin can edit body
if current_status == "open" and task.assignee_id is not None:
from app.api.rbac import is_global_admin, has_project_admin_role
is_admin = (
is_global_admin(db, current_user.id)
or has_project_admin_role(db, current_user.id, task.project_id)
)
if current_user.id != task.assignee_id and not is_admin:
raise HTTPException(
status_code=403,
detail="Only the current assignee or an admin can edit this task",
)
# Legacy general permission check (covers project membership etc.)
ensure_can_edit_task(db, current_user.id, task)
if "status" in update_data: if "status" in update_data:
new_status = update_data["status"] new_status = update_data["status"]
old_status = task.status.value if hasattr(task.status, 'value') else task.status old_status = task.status.value if hasattr(task.status, 'value') else task.status
@@ -223,8 +269,19 @@ def delete_task(task_id: int, db: Session = Depends(get_db), current_user: model
# ---- Transition ---- # ---- Transition ----
class TransitionBody(BaseModel):
comment: Optional[str] = None
@router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse) @router.post("/tasks/{task_id}/transition", response_model=schemas.TaskResponse)
def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Session = Depends(get_db)): def transition_task(
task_id: int,
new_status: str,
bg: BackgroundTasks,
body: TransitionBody = None,
db: Session = Depends(get_db),
current_user: models.User = Depends(get_current_user_or_apikey),
):
valid_statuses = [s.value for s in TaskStatus] valid_statuses = [s.value for s in TaskStatus]
if new_status not in valid_statuses: if new_status not in valid_statuses:
raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}") raise HTTPException(status_code=400, detail=f"Invalid status. Must be one of: {valid_statuses}")
@@ -247,10 +304,21 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess
detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'", detail=f"Cannot open task: milestone is '{ms_status}', must be 'undergoing'",
) )
# P5.3: open -> undergoing requires assignee # P5.3: open -> undergoing requires assignee AND operator must be the assignee
if old_status == "open" and new_status == "undergoing": if old_status == "open" and new_status == "undergoing":
if not task.assignee_id: if not task.assignee_id:
raise HTTPException(status_code=400, detail="Cannot start task: assignee must be set first") raise HTTPException(status_code=400, detail="Cannot start task: assignee must be set first")
if current_user.id != task.assignee_id:
raise HTTPException(status_code=403, detail="Only the assigned user can start this task")
# P5.4: undergoing -> completed requires a completion comment
if old_status == "undergoing" and new_status == "completed":
comment_text = body.comment if body else None
if not comment_text or not comment_text.strip():
raise HTTPException(status_code=400, detail="A completion comment is required when finishing a task")
# P5.4: also only the assignee can complete
if task.assignee_id and current_user.id != task.assignee_id:
raise HTTPException(status_code=403, detail="Only the assigned user can complete this task")
# P5.6: reopen from completed/closed -> open # P5.6: reopen from completed/closed -> open
if new_status == "open" and old_status in ("completed", "closed"): if new_status == "open" and old_status in ("completed", "closed"):
@@ -265,10 +333,24 @@ def transition_task(task_id: int, new_status: str, bg: BackgroundTasks, db: Sess
db.commit() db.commit()
db.refresh(task) db.refresh(task)
# P5.4: auto-create completion comment
if old_status == "undergoing" and new_status == "completed" and body and body.comment:
db_comment = models.Comment(
content=body.comment.strip(),
task_id=task.id,
author_id=current_user.id,
)
db.add(db_comment)
db.commit()
# Log the transition activity
log_activity(db, f"task.transition.{new_status}", "task", task.id, current_user.id,
{"old_status": old_status, "new_status": new_status})
# P3.5: auto-complete milestone when its sole release task is completed # P3.5: auto-complete milestone when its sole release task is completed
if new_status == "completed": if new_status == "completed":
from app.api.routers.milestone_actions import try_auto_complete_milestone from app.api.routers.milestone_actions import try_auto_complete_milestone
try_auto_complete_milestone(db, task, user_id=None) try_auto_complete_milestone(db, task, user_id=current_user.id)
event = "task.closed" if new_status == "closed" else "task.updated" event = "task.closed" if new_status == "closed" else "task.updated"
bg.add_task(fire_webhooks_sync, event, bg.add_task(fire_webhooks_sync, event,