BE-CAL-API-003: implement Calendar edit API for real and virtual slots

- Add TimeSlotEdit schema (partial update, all fields optional)
- Add TimeSlotEditResponse schema
- Add PATCH /calendar/slots/{slot_id} for editing real slots
- Add PATCH /calendar/slots/virtual/{virtual_id} for editing virtual slots
  - Triggers materialization before applying edits
  - Detaches from plan after edit
- Both endpoints enforce past-slot immutability, overlap detection, plan
  detachment, and workload warnings
This commit is contained in:
zhi
2026-03-31 10:46:09 +00:00
parent c75ded02c8
commit f7f9ba3aa7
2 changed files with 244 additions and 2 deletions

View File

@@ -3,6 +3,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).
"""
from datetime import date as date_type
@@ -24,6 +25,8 @@ from app.schemas.calendar import (
SlotConflictItem,
TimeSlotCreate,
TimeSlotCreateResponse,
TimeSlotEdit,
TimeSlotEditResponse,
TimeSlotResponse,
)
from app.services.minimum_workload import (
@@ -32,8 +35,18 @@ from app.services.minimum_workload import (
replace_workload_config,
upsert_workload_config,
)
from app.services.overlap import check_overlap_for_create
from app.services.plan_slot import get_virtual_slots_for_date
from app.services.overlap import check_overlap_for_create, check_overlap_for_edit
from app.services.plan_slot import (
detach_slot_from_plan,
get_virtual_slots_for_date,
materialize_from_virtual_id,
parse_virtual_slot_id,
)
from app.services.slot_immutability import (
ImmutableSlotError,
guard_edit_real_slot,
guard_edit_virtual_slot,
)
router = APIRouter(prefix="/calendar", tags=["Calendar"])
@@ -244,6 +257,191 @@ def get_calendar_day(
)
# ---------------------------------------------------------------------------
# Slot edit (BE-CAL-API-003)
# ---------------------------------------------------------------------------
def _apply_edit_fields(slot: TimeSlot, payload: TimeSlotEdit) -> None:
"""Apply non-None fields from *payload* to a TimeSlot ORM object."""
if payload.slot_type is not None:
slot.slot_type = payload.slot_type.value
if payload.scheduled_at is not None:
slot.scheduled_at = payload.scheduled_at
if payload.estimated_duration is not None:
slot.estimated_duration = payload.estimated_duration
if payload.event_type is not None:
slot.event_type = payload.event_type.value
if payload.event_data is not None:
slot.event_data = payload.event_data
if payload.priority is not None:
slot.priority = payload.priority
@router.patch(
"/slots/{slot_id}",
response_model=TimeSlotEditResponse,
summary="Edit a real (materialized) calendar slot",
)
def edit_real_slot(
slot_id: int,
payload: TimeSlotEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Edit an existing real (materialized) slot.
- **Immutability**: rejects edits to past slots.
- **Overlap detection**: if time/duration changed, rejects on overlap
(excluding the slot being edited).
- **Plan detach**: if the slot was materialized from a plan, editing
detaches it (sets ``plan_id`` to NULL).
- **Workload warnings**: returned after successful edit (advisory only).
"""
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_edit_real_slot(db, slot)
except ImmutableSlotError as e:
raise HTTPException(status_code=422, detail=str(e))
# --- Determine effective time/duration for overlap check ---
effective_scheduled_at = payload.scheduled_at if payload.scheduled_at is not None else slot.scheduled_at
effective_duration = payload.estimated_duration if payload.estimated_duration is not None else slot.estimated_duration
# --- Overlap check (if time or duration changed) ---
time_changed = (
(payload.scheduled_at is not None and payload.scheduled_at != slot.scheduled_at)
or (payload.estimated_duration is not None and payload.estimated_duration != slot.estimated_duration)
)
if time_changed:
conflicts = check_overlap_for_edit(
db,
user_id=current_user.id,
slot_id=slot.id,
target_date=slot.date,
scheduled_at=effective_scheduled_at,
estimated_duration=effective_duration,
)
if conflicts:
raise HTTPException(
status_code=409,
detail={
"message": "Edited slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Detach from plan if applicable ---
if slot.plan_id is not None:
detach_slot_from_plan(slot)
# --- Apply edits ---
_apply_edit_fields(slot, payload)
db.commit()
db.refresh(slot)
# --- Workload warnings ---
warnings = get_workload_warnings_for_date(db, current_user.id, slot.date)
return TimeSlotEditResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
@router.patch(
"/slots/virtual/{virtual_id}",
response_model=TimeSlotEditResponse,
summary="Edit a virtual (plan-generated) calendar slot",
)
def edit_virtual_slot(
virtual_id: str,
payload: TimeSlotEdit,
db: Session = Depends(get_db),
current_user: User = Depends(get_current_user),
):
"""Edit a virtual (plan-generated) slot.
This triggers **materialization**: the virtual slot is first converted
into a real TimeSlot row, then the edits are applied, and the slot is
detached from its plan (``plan_id`` set to NULL).
- **Immutability**: rejects edits to past virtual slots.
- **Overlap detection**: checks overlap with the edited time/duration
against existing slots on the same day.
- **Workload warnings**: returned after successful edit (advisory only).
"""
# --- 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_edit_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")
# --- Determine effective time/duration for overlap check ---
effective_scheduled_at = payload.scheduled_at if payload.scheduled_at is not None else slot.scheduled_at
effective_duration = payload.estimated_duration if payload.estimated_duration is not None else slot.estimated_duration
# --- Overlap check (exclude newly materialized slot) ---
conflicts = check_overlap_for_edit(
db,
user_id=current_user.id,
slot_id=slot.id,
target_date=slot.date,
scheduled_at=effective_scheduled_at,
estimated_duration=effective_duration,
)
if conflicts:
db.rollback()
raise HTTPException(
status_code=409,
detail={
"message": "Edited slot overlaps with existing schedule",
"conflicts": [c.to_dict() for c in conflicts],
},
)
# --- Detach from plan ---
detach_slot_from_plan(slot)
# --- Apply edits ---
_apply_edit_fields(slot, payload)
db.commit()
db.refresh(slot)
# --- Workload warnings ---
warnings = get_workload_warnings_for_date(db, current_user.id, slot.date)
return TimeSlotEditResponse(
slot=_slot_to_response(slot),
warnings=warnings,
)
# ---------------------------------------------------------------------------
# MinimumWorkload
# ---------------------------------------------------------------------------