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

@@ -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-003: Calendar slot edit endpoints (real + virtual).
BE-CAL-API-004: Calendar slot cancel 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-005: Plan schedule / plan list endpoints.
BE-CAL-API-006: Plan edit / plan cancel endpoints.
""" """
from datetime import date as date_type from datetime import date as date_type
@@ -24,7 +25,9 @@ from app.schemas.calendar import (
MinimumWorkloadConfig, MinimumWorkloadConfig,
MinimumWorkloadResponse, MinimumWorkloadResponse,
MinimumWorkloadUpdate, MinimumWorkloadUpdate,
SchedulePlanCancelResponse,
SchedulePlanCreate, SchedulePlanCreate,
SchedulePlanEdit,
SchedulePlanListResponse, SchedulePlanListResponse,
SchedulePlanResponse, SchedulePlanResponse,
SlotConflictItem, SlotConflictItem,
@@ -54,6 +57,8 @@ from app.services.slot_immutability import (
guard_cancel_virtual_slot, guard_cancel_virtual_slot,
guard_edit_real_slot, guard_edit_real_slot,
guard_edit_virtual_slot, guard_edit_virtual_slot,
guard_plan_cancel_no_past_retroaction,
guard_plan_edit_no_past_retroaction,
) )
router = APIRouter(prefix="/calendar", tags=["Calendar"]) router = APIRouter(prefix="/calendar", tags=["Calendar"])
@@ -664,6 +669,178 @@ def get_plan(
return _plan_to_response(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 # MinimumWorkload
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -337,3 +337,57 @@ class SchedulePlanResponse(BaseModel):
class SchedulePlanListResponse(BaseModel): class SchedulePlanListResponse(BaseModel):
"""Response for listing schedule plans.""" """Response for listing schedule plans."""
plans: list[SchedulePlanResponse] = Field(default_factory=list) 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",
)