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
This commit is contained in:
zhi
2026-03-31 16:46:18 +00:00
parent 43cf22b654
commit 78d836c71e
2 changed files with 231 additions and 0 deletions

View File

@@ -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",
)