From 78d836c71ef0a4404718515abd6d6121ecda3487 Mon Sep 17 00:00:00 2001 From: zhi Date: Tue, 31 Mar 2026 16:46:18 +0000 Subject: [PATCH] BE-CAL-API-006: implement plan-edit and plan-cancel API endpoints - PATCH /calendar/plans/{plan_id}: edit a recurring schedule plan - Validates period-parameter hierarchy after merge - Rejects edits to inactive (cancelled) plans - Detaches future materialized slots so they keep old data - Past materialized slots remain untouched - POST /calendar/plans/{plan_id}/cancel: cancel (soft-delete) a plan - Sets is_active=False - Detaches future materialized slots (plan_id -> NULL) - Preserves past materialized slots, returns their IDs - Added SchedulePlanEdit and SchedulePlanCancelResponse schemas --- app/api/routers/calendar.py | 177 ++++++++++++++++++++++++++++++++++++ app/schemas/calendar.py | 54 +++++++++++ 2 files changed, 231 insertions(+) diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index f624ca7..c7d8eb4 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -6,6 +6,7 @@ BE-CAL-API-002: Day-view calendar query endpoint. BE-CAL-API-003: Calendar slot edit endpoints (real + virtual). BE-CAL-API-004: Calendar slot cancel endpoints (real + virtual). BE-CAL-API-005: Plan schedule / plan list endpoints. +BE-CAL-API-006: Plan edit / plan cancel endpoints. """ from datetime import date as date_type @@ -24,7 +25,9 @@ from app.schemas.calendar import ( MinimumWorkloadConfig, MinimumWorkloadResponse, MinimumWorkloadUpdate, + SchedulePlanCancelResponse, SchedulePlanCreate, + SchedulePlanEdit, SchedulePlanListResponse, SchedulePlanResponse, SlotConflictItem, @@ -54,6 +57,8 @@ from app.services.slot_immutability import ( guard_cancel_virtual_slot, guard_edit_real_slot, guard_edit_virtual_slot, + guard_plan_cancel_no_past_retroaction, + guard_plan_edit_no_past_retroaction, ) router = APIRouter(prefix="/calendar", tags=["Calendar"]) @@ -664,6 +669,178 @@ def get_plan( return _plan_to_response(plan) +# --------------------------------------------------------------------------- +# Plan edit / cancel (BE-CAL-API-006) +# --------------------------------------------------------------------------- + +def _validate_plan_hierarchy( + on_day: str | None, + on_week: int | None, + on_month: str | None, +) -> None: + """Enforce period-parameter hierarchy after merging edited values. + + Raises HTTPException(422) if the hierarchy is violated. + """ + if on_month is not None and on_week is None: + raise HTTPException( + status_code=422, + detail="on_month requires on_week to be set", + ) + if on_week is not None and on_day is None: + raise HTTPException( + status_code=422, + detail="on_week requires on_day to be set", + ) + + +@router.patch( + "/plans/{plan_id}", + response_model=SchedulePlanResponse, + summary="Edit a recurring schedule plan", +) +def edit_plan( + plan_id: int, + payload: SchedulePlanEdit, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Edit an existing schedule plan. + + - Only **future** virtual/materialized slots are affected; past + materialized slots remain untouched. + - Period-parameter hierarchy (``on_month`` → ``on_week`` → ``on_day``) + is validated after merging edited values with existing plan values. + - Inactive (cancelled) plans cannot be edited. + """ + plan = ( + db.query(SchedulePlan) + .filter(SchedulePlan.id == plan_id, SchedulePlan.user_id == current_user.id) + .first() + ) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found") + + if not plan.is_active: + raise HTTPException(status_code=422, detail="Cannot edit an inactive (cancelled) plan") + + # --- Identify past slots that must NOT be touched --- + _past_ids = guard_plan_edit_no_past_retroaction(db, plan_id) + + # --- Apply clear flags first (set to NULL) --- + if payload.clear_on_month: + plan.on_month = None + if payload.clear_on_week: + plan.on_week = None + if payload.clear_on_day: + plan.on_day = None + + # --- Apply provided values --- + if payload.slot_type is not None: + plan.slot_type = payload.slot_type.value + if payload.estimated_duration is not None: + plan.estimated_duration = payload.estimated_duration + if payload.at_time is not None: + plan.at_time = payload.at_time + if payload.on_day is not None: + plan.on_day = payload.on_day.value + if payload.on_week is not None: + plan.on_week = payload.on_week + if payload.on_month is not None: + plan.on_month = payload.on_month.value + if payload.event_type is not None: + plan.event_type = payload.event_type.value + if payload.event_data is not None: + plan.event_data = payload.event_data + + # --- Validate hierarchy with merged values --- + effective_on_day = plan.on_day + effective_on_week = plan.on_week + effective_on_month = plan.on_month + _validate_plan_hierarchy(effective_on_day, effective_on_week, effective_on_month) + + # --- Detach future materialized slots so they keep old data --- + # Future materialized slots with plan_id set are detached because + # they were generated from the old plan template. New virtual slots + # will reflect the updated plan going forward. + from datetime import date as date_type + today = date_type.today() + future_materialized = ( + db.query(TimeSlot) + .filter( + TimeSlot.plan_id == plan_id, + TimeSlot.date >= today, + ) + .all() + ) + for slot in future_materialized: + slot.plan_id = None + + db.commit() + db.refresh(plan) + + return _plan_to_response(plan) + + +@router.post( + "/plans/{plan_id}/cancel", + response_model=SchedulePlanCancelResponse, + summary="Cancel a recurring schedule plan", +) +def cancel_plan( + plan_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Cancel (soft-delete) a schedule plan. + + - Sets the plan's ``is_active`` flag to ``False``. + - **Past** materialized slots are preserved untouched. + - **Future** materialized slots that still reference this plan are + detached (``plan_id`` set to NULL) so they remain on the calendar + as standalone slots. If you want to also cancel those future slots, + cancel them individually via the slot-cancel endpoints. + """ + plan = ( + db.query(SchedulePlan) + .filter(SchedulePlan.id == plan_id, SchedulePlan.user_id == current_user.id) + .first() + ) + if plan is None: + raise HTTPException(status_code=404, detail="Plan not found") + + if not plan.is_active: + raise HTTPException(status_code=422, detail="Plan is already cancelled") + + # --- Identify past slots that must NOT be touched --- + past_ids = guard_plan_cancel_no_past_retroaction(db, plan_id) + + # --- Detach future materialized slots --- + from datetime import date as date_type + today = date_type.today() + future_materialized = ( + db.query(TimeSlot) + .filter( + TimeSlot.plan_id == plan_id, + TimeSlot.date >= today, + ) + .all() + ) + for slot in future_materialized: + slot.plan_id = None + + # --- Deactivate the plan --- + plan.is_active = False + db.commit() + db.refresh(plan) + + return SchedulePlanCancelResponse( + plan=_plan_to_response(plan), + message="Plan cancelled successfully", + preserved_past_slot_ids=past_ids, + ) + + # --------------------------------------------------------------------------- # MinimumWorkload # --------------------------------------------------------------------------- diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py index f12456e..751ac2a 100644 --- a/app/schemas/calendar.py +++ b/app/schemas/calendar.py @@ -337,3 +337,57 @@ class SchedulePlanResponse(BaseModel): class SchedulePlanListResponse(BaseModel): """Response for listing schedule plans.""" plans: list[SchedulePlanResponse] = Field(default_factory=list) + + +# --------------------------------------------------------------------------- +# SchedulePlan edit / cancel (BE-CAL-API-006) +# --------------------------------------------------------------------------- + +class SchedulePlanEdit(BaseModel): + """Request body for editing a recurring schedule plan. + + All fields are optional — only provided fields are updated. + Period-parameter hierarchy (on_month → on_week → on_day) is + validated after merging with existing plan values. + """ + slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type") + estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)") + at_time: Optional[time] = Field(None, description="New daily time (HH:MM)") + on_day: Optional[DayOfWeekEnum] = Field(None, description="New day of week (sun-sat), use 'clear' param to remove") + on_week: Optional[int] = Field(None, ge=1, le=4, description="New week of month (1-4), use 'clear' param to remove") + on_month: Optional[MonthOfYearEnum] = Field(None, description="New month (jan-dec), use 'clear' param to remove") + event_type: Optional[EventTypeEnum] = Field(None, description="New event type") + event_data: Optional[dict[str, Any]] = Field(None, description="New event details JSON") + clear_on_day: bool = Field(False, description="Clear on_day (set to NULL)") + clear_on_week: bool = Field(False, description="Clear on_week (set to NULL)") + clear_on_month: bool = Field(False, description="Clear on_month (set to NULL)") + + @field_validator("at_time") + @classmethod + def _validate_at_time(cls, v: Optional[time]) -> Optional[time]: + if v is not None and v.hour > 23: + raise ValueError("at_time hour must be between 00 and 23") + return v + + @model_validator(mode="after") + def _at_least_one_field(self) -> "SchedulePlanEdit": + """Ensure at least one editable field or clear flag is provided.""" + has_value = any( + getattr(self, f) is not None + for f in ("slot_type", "estimated_duration", "at_time", "on_day", + "on_week", "on_month", "event_type", "event_data") + ) + has_clear = self.clear_on_day or self.clear_on_week or self.clear_on_month + if not has_value and not has_clear: + raise ValueError("At least one field must be provided for edit") + return self + + +class SchedulePlanCancelResponse(BaseModel): + """Response after cancelling a plan.""" + plan: SchedulePlanResponse + message: str = Field("Plan cancelled successfully", description="Human-readable result") + preserved_past_slot_ids: list[int] = Field( + default_factory=list, + description="IDs of past materialized slots that were NOT affected", + )