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-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
# ---------------------------------------------------------------------------