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:
@@ -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
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -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",
|
||||||
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user