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