diff --git a/app/api/routers/calendar.py b/app/api/routers/calendar.py index 3af8c1d..da3fc82 100644 --- a/app/api/routers/calendar.py +++ b/app/api/routers/calendar.py @@ -4,6 +4,7 @@ BE-CAL-004: MinimumWorkload CRUD endpoints. BE-CAL-API-001: Single-slot creation endpoint. 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). """ from datetime import date as date_type @@ -23,6 +24,7 @@ from app.schemas.calendar import ( MinimumWorkloadResponse, MinimumWorkloadUpdate, SlotConflictItem, + TimeSlotCancelResponse, TimeSlotCreate, TimeSlotCreateResponse, TimeSlotEdit, @@ -44,6 +46,8 @@ from app.services.plan_slot import ( ) from app.services.slot_immutability import ( ImmutableSlotError, + guard_cancel_real_slot, + guard_cancel_virtual_slot, guard_edit_real_slot, guard_edit_virtual_slot, ) @@ -442,6 +446,113 @@ def edit_virtual_slot( ) +# --------------------------------------------------------------------------- +# Slot cancel (BE-CAL-API-004) +# --------------------------------------------------------------------------- + +@router.post( + "/slots/{slot_id}/cancel", + response_model=TimeSlotCancelResponse, + summary="Cancel a real (materialized) calendar slot", +) +def cancel_real_slot( + slot_id: int, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Cancel an existing real (materialized) slot. + + - **Immutability**: rejects cancellation of past slots. + - **Plan detach**: if the slot was materialized from a plan, cancelling + detaches it (sets ``plan_id`` to NULL) so the plan no longer claims + that date. + - **Status**: sets the slot status to ``Skipped``. + """ + slot = ( + db.query(TimeSlot) + .filter(TimeSlot.id == slot_id, TimeSlot.user_id == current_user.id) + .first() + ) + if slot is None: + raise HTTPException(status_code=404, detail="Slot not found") + + # --- Past-slot guard --- + try: + guard_cancel_real_slot(db, slot) + except ImmutableSlotError as e: + raise HTTPException(status_code=422, detail=str(e)) + + # --- Detach from plan if applicable --- + if slot.plan_id is not None: + detach_slot_from_plan(slot) + + # --- Update status --- + slot.status = SlotStatus.SKIPPED + db.commit() + db.refresh(slot) + + return TimeSlotCancelResponse( + slot=_slot_to_response(slot), + message="Slot cancelled successfully", + ) + + +@router.post( + "/slots/virtual/{virtual_id}/cancel", + response_model=TimeSlotCancelResponse, + summary="Cancel a virtual (plan-generated) calendar slot", +) +def cancel_virtual_slot( + virtual_id: str, + db: Session = Depends(get_db), + current_user: User = Depends(get_current_user), +): + """Cancel a virtual (plan-generated) slot. + + This triggers **materialization**: the virtual slot is first converted + into a real TimeSlot row, then immediately set to ``Skipped`` status + and detached from its plan (``plan_id`` set to NULL). + + - **Immutability**: rejects cancellation of past virtual slots. + """ + # --- Validate virtual_id format --- + parsed = parse_virtual_slot_id(virtual_id) + if parsed is None: + raise HTTPException(status_code=400, detail=f"Invalid virtual slot id: {virtual_id}") + + plan_id, slot_date = parsed + + # --- Past-slot guard --- + try: + guard_cancel_virtual_slot(virtual_id) + except ImmutableSlotError as e: + raise HTTPException(status_code=422, detail=str(e)) + + # --- Materialize --- + try: + slot = materialize_from_virtual_id(db, virtual_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # --- Verify ownership --- + if slot.user_id != current_user.id: + db.rollback() + raise HTTPException(status_code=404, detail="Slot not found") + + # --- Detach from plan --- + detach_slot_from_plan(slot) + + # --- Update status --- + slot.status = SlotStatus.SKIPPED + db.commit() + db.refresh(slot) + + return TimeSlotCancelResponse( + slot=_slot_to_response(slot), + message="Virtual slot materialized and cancelled successfully", + ) + + # --------------------------------------------------------------------------- # MinimumWorkload # --------------------------------------------------------------------------- diff --git a/app/schemas/calendar.py b/app/schemas/calendar.py index 607778b..4ed4582 100644 --- a/app/schemas/calendar.py +++ b/app/schemas/calendar.py @@ -4,6 +4,7 @@ BE-CAL-004: MinimumWorkload read/write schemas. BE-CAL-API-001: TimeSlot create / response schemas. BE-CAL-API-002: Calendar day-view query schemas. BE-CAL-API-003: TimeSlot edit schemas. +BE-CAL-API-004: TimeSlot cancel schemas. """ from __future__ import annotations @@ -240,3 +241,13 @@ class CalendarDayResponse(BaseModel): default_factory=list, description="All slots for the day, sorted by scheduled_at ascending", ) + + +# --------------------------------------------------------------------------- +# TimeSlot cancel (BE-CAL-API-004) +# --------------------------------------------------------------------------- + +class TimeSlotCancelResponse(BaseModel): + """Response after cancelling a slot — includes the cancelled slot.""" + slot: TimeSlotResponse + message: str = Field("Slot cancelled successfully", description="Human-readable result")