From 314040cef5090d1d1a69b7f7701534066d9a10d9 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 17 Mar 2026 09:01:40 +0000 Subject: [PATCH] =?UTF-8?q?feat(P3.6):=20milestone=20edit=20restrictions?= =?UTF-8?q?=20=E2=80=94=20block=20PATCH=20in=20terminal=20states,=20restri?= =?UTF-8?q?ct=20scope=20fields=20in=20freeze/undergoing,=20protect=20delet?= =?UTF-8?q?e?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/api/routers/milestones.py | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/app/api/routers/milestones.py b/app/api/routers/milestones.py index a5a5e72..27ee997 100644 --- a/app/api/routers/milestones.py +++ b/app/api/routers/milestones.py @@ -82,7 +82,36 @@ def update_milestone(project_id: int, milestone_id: int, milestone: schemas.Mile if not db_milestone: raise HTTPException(status_code=404, detail="Milestone not found") 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) + + # 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: data["depend_on_milestones"] = json.dumps(data["depend_on_milestones"]) if data["depend_on_milestones"] else None 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() if not db_milestone: 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.commit() return None