Compare commits
4 Commits
314040cef5
...
c18b8f3850
| Author | SHA1 | Date | |
|---|---|---|---|
| c18b8f3850 | |||
| 7542f2d7c1 | |||
| ffb0fa6058 | |||
| 7a16639aac |
@@ -53,6 +53,90 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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
|
||||
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
|
||||
# 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_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":
|
||||
raise HTTPException(status_code=400, detail="Cannot add feature story tasks after milestone is frozen")
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Tasks router — replaces the old issues router. All CRUD operates on the `tasks` table."""
|
||||
import math
|
||||
from typing import List
|
||||
from typing import List, Optional
|
||||
from datetime import datetime
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
||||
from sqlalchemy.orm import Session
|
||||
@@ -52,7 +52,17 @@ TASK_SUBTYPE_MAP = {
|
||||
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:
|
||||
return
|
||||
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())
|
||||
if task_subtype and task_subtype not in allowed:
|
||||
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):
|
||||
@@ -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()
|
||||
if not task:
|
||||
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)
|
||||
|
||||
# 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:
|
||||
new_status = update_data["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 ----
|
||||
|
||||
class TransitionBody(BaseModel):
|
||||
comment: Optional[str] = None
|
||||
|
||||
|
||||
@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]
|
||||
if new_status not in 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'",
|
||||
)
|
||||
|
||||
# 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 not task.assignee_id:
|
||||
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
|
||||
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.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
|
||||
if new_status == "completed":
|
||||
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"
|
||||
bg.add_task(fire_webhooks_sync, event,
|
||||
|
||||
Reference in New Issue
Block a user