feat(P3.6): milestone edit restrictions — block PATCH in terminal states, restrict scope fields in freeze/undergoing, protect delete

This commit is contained in:
zhi
2026-03-17 09:01:40 +00:00
parent 589b1cc8de
commit 314040cef5

View File

@@ -82,7 +82,36 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile
if not db_milestone: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
ensure_can_edit_milestone(db, current_user.id, db_milestone) ensure_can_edit_milestone(db, current_user.id, db_milestone)
# --- P3.6 Milestone edit restrictions based on status ---
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
# Terminal states: no edits allowed
if ms_status in ("completed", "closed"):
raise HTTPException(
status_code=400,
detail=f"Cannot edit a milestone that is '{ms_status}'. No modifications are allowed in terminal state."
)
data = milestone.model_dump(exclude_unset=True) data = milestone.model_dump(exclude_unset=True)
# Never allow status changes via PATCH — use action endpoints instead
if "status" in data:
raise HTTPException(
status_code=400,
detail="Milestone status cannot be changed via PATCH. Use the action endpoints (freeze/start/close) instead."
)
# Freeze / undergoing: restrict scope-changing fields
SCOPE_FIELDS = {"title", "description", "due_date", "planned_release_date", "depend_on_milestones", "depend_on_tasks"}
if ms_status in ("freeze", "undergoing"):
blocked = SCOPE_FIELDS & set(data.keys())
if blocked:
raise HTTPException(
status_code=400,
detail=f"Cannot modify scope fields {sorted(blocked)} when milestone is '{ms_status}'. Scope changes are only allowed in 'open' status."
)
if "depend_on_milestones" in data: if "depend_on_milestones" in data:
data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None
if "depend_on_tasks" in data: if "depend_on_tasks" in data:
@@ -100,6 +129,9 @@ def delete_milestone(project_id: int, milestone_id: int, db: Session = Depends(g
db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first() db_milestone = db.query(Milestone).filter(Milestone.id == milestone_id, Milestone.project_id == project_id).first()
if not db_milestone: if not db_milestone:
raise HTTPException(status_code=404, detail="Milestone not found") raise HTTPException(status_code=404, detail="Milestone not found")
ms_status = db_milestone.status.value if hasattr(db_milestone.status, 'value') else db_milestone.status
if ms_status in ("undergoing", "completed"):
raise HTTPException(status_code=400, detail=f"Cannot delete a milestone that is '{ms_status}'")
db.delete(db_milestone) db.delete(db_milestone)
db.commit() db.commit()
return None return None