BE-CAL-API-004: Implement Calendar cancel API for real and virtual slots
- Add POST /calendar/slots/{slot_id}/cancel for real slot cancellation
- Add POST /calendar/slots/virtual/{virtual_id}/cancel for virtual slot cancellation
- Virtual cancel materializes the slot first, then marks as Skipped
- Both endpoints enforce past-slot immutability guard
- Both endpoints detach from plan (set plan_id=NULL)
- Status set to SlotStatus.SKIPPED on cancel
- Add TimeSlotCancelResponse schema
This commit is contained in:
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user