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:
@@ -3,6 +3,7 @@
|
|||||||
BE-CAL-004: MinimumWorkload CRUD endpoints.
|
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).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from datetime import date as date_type
|
from datetime import date as date_type
|
||||||
@@ -24,6 +25,8 @@ from app.schemas.calendar import (
|
|||||||
SlotConflictItem,
|
SlotConflictItem,
|
||||||
TimeSlotCreate,
|
TimeSlotCreate,
|
||||||
TimeSlotCreateResponse,
|
TimeSlotCreateResponse,
|
||||||
|
TimeSlotEdit,
|
||||||
|
TimeSlotEditResponse,
|
||||||
TimeSlotResponse,
|
TimeSlotResponse,
|
||||||
)
|
)
|
||||||
from app.services.minimum_workload import (
|
from app.services.minimum_workload import (
|
||||||
@@ -32,8 +35,18 @@ from app.services.minimum_workload import (
|
|||||||
replace_workload_config,
|
replace_workload_config,
|
||||||
upsert_workload_config,
|
upsert_workload_config,
|
||||||
)
|
)
|
||||||
from app.services.overlap import check_overlap_for_create
|
from app.services.overlap import check_overlap_for_create, check_overlap_for_edit
|
||||||
from app.services.plan_slot import get_virtual_slots_for_date
|
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"])
|
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
|
# MinimumWorkload
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
BE-CAL-004: MinimumWorkload read/write schemas.
|
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.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -155,6 +156,49 @@ class TimeSlotCreateResponse(BaseModel):
|
|||||||
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# TimeSlot edit (BE-CAL-API-003)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
class TimeSlotEdit(BaseModel):
|
||||||
|
"""Request body for editing a calendar slot.
|
||||||
|
|
||||||
|
All fields are optional — only provided fields are updated.
|
||||||
|
The caller must supply either ``slot_id`` (for real slots) or
|
||||||
|
``virtual_id`` (for plan-generated virtual slots) in the URL path.
|
||||||
|
"""
|
||||||
|
slot_type: Optional[SlotTypeEnum] = Field(None, description="New slot type")
|
||||||
|
scheduled_at: Optional[time] = Field(None, description="New start time HH:MM")
|
||||||
|
estimated_duration: Optional[int] = Field(None, ge=1, le=50, description="New duration in minutes (1-50)")
|
||||||
|
event_type: Optional[EventTypeEnum] = Field(None, description="New event type")
|
||||||
|
event_data: Optional[dict[str, Any]] = Field(None, description="New event details JSON")
|
||||||
|
priority: Optional[int] = Field(None, ge=0, le=99, description="New priority 0-99")
|
||||||
|
|
||||||
|
@field_validator("scheduled_at")
|
||||||
|
@classmethod
|
||||||
|
def _validate_scheduled_at(cls, v: Optional[time]) -> Optional[time]:
|
||||||
|
if v is not None and v.hour > 23:
|
||||||
|
raise ValueError("scheduled_at hour must be between 00 and 23")
|
||||||
|
return v
|
||||||
|
|
||||||
|
@model_validator(mode="after")
|
||||||
|
def _at_least_one_field(self) -> "TimeSlotEdit":
|
||||||
|
"""Ensure at least one editable field is provided."""
|
||||||
|
if all(
|
||||||
|
getattr(self, f) is None
|
||||||
|
for f in ("slot_type", "scheduled_at", "estimated_duration",
|
||||||
|
"event_type", "event_data", "priority")
|
||||||
|
):
|
||||||
|
raise ValueError("At least one field must be provided for edit")
|
||||||
|
return self
|
||||||
|
|
||||||
|
|
||||||
|
class TimeSlotEditResponse(BaseModel):
|
||||||
|
"""Response after editing a slot — includes the updated slot and any warnings."""
|
||||||
|
slot: TimeSlotResponse
|
||||||
|
warnings: list[WorkloadWarningItem] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Calendar day-view query (BE-CAL-API-002)
|
# Calendar day-view query (BE-CAL-API-002)
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
|
|||||||
Reference in New Issue
Block a user