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-001: Single-slot creation endpoint.
|
||||||
BE-CAL-API-002: Day-view calendar query endpoint.
|
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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
@@ -23,6 +24,7 @@ from app.schemas.calendar import (
|
|||||||
MinimumWorkloadResponse,
|
MinimumWorkloadResponse,
|
||||||
MinimumWorkloadUpdate,
|
MinimumWorkloadUpdate,
|
||||||
SlotConflictItem,
|
SlotConflictItem,
|
||||||
|
TimeSlotCancelResponse,
|
||||||
TimeSlotCreate,
|
TimeSlotCreate,
|
||||||
TimeSlotCreateResponse,
|
TimeSlotCreateResponse,
|
||||||
TimeSlotEdit,
|
TimeSlotEdit,
|
||||||
@@ -44,6 +46,8 @@ from app.services.plan_slot import (
|
|||||||
)
|
)
|
||||||
from app.services.slot_immutability import (
|
from app.services.slot_immutability import (
|
||||||
ImmutableSlotError,
|
ImmutableSlotError,
|
||||||
|
guard_cancel_real_slot,
|
||||||
|
guard_cancel_virtual_slot,
|
||||||
guard_edit_real_slot,
|
guard_edit_real_slot,
|
||||||
guard_edit_virtual_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
|
# MinimumWorkload
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ BE-CAL-004: MinimumWorkload read/write schemas.
|
|||||||
BE-CAL-API-001: TimeSlot create / response schemas.
|
BE-CAL-API-001: TimeSlot create / response schemas.
|
||||||
BE-CAL-API-002: Calendar day-view query schemas.
|
BE-CAL-API-002: Calendar day-view query schemas.
|
||||||
BE-CAL-API-003: TimeSlot edit schemas.
|
BE-CAL-API-003: TimeSlot edit schemas.
|
||||||
|
BE-CAL-API-004: TimeSlot cancel schemas.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -240,3 +241,13 @@ class CalendarDayResponse(BaseModel):
|
|||||||
default_factory=list,
|
default_factory=list,
|
||||||
description="All slots for the day, sorted by scheduled_at ascending",
|
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