"""Past-slot immutability rules. BE-CAL-008: Prevents editing or cancelling slots whose date is in the past. Also ensures plan-edit and plan-cancel do not retroactively affect already-materialized past slots. Rules: 1. Editing a past slot (real or virtual) → raise ImmutableSlotError 2. Cancelling a past slot (real or virtual) → raise ImmutableSlotError 3. Plan-edit / plan-cancel must NOT retroactively change already-materialized slots whose date is in the past. The plan_slot.detach_slot_from_plan() mechanism already ensures this: past materialized slots keep their data. This module provides guard functions that Calendar API endpoints call before performing mutations. """ from __future__ import annotations from datetime import date from typing import Optional from sqlalchemy.orm import Session from app.models.calendar import TimeSlot from app.services.plan_slot import parse_virtual_slot_id class ImmutableSlotError(Exception): """Raised when an operation attempts to modify a past slot.""" def __init__(self, slot_date: date, operation: str, detail: str = ""): self.slot_date = slot_date self.operation = operation self.detail = detail msg = ( f"Cannot {operation} slot on {slot_date.isoformat()}: " f"past slots are immutable" ) if detail: msg += f" ({detail})" super().__init__(msg) # --------------------------------------------------------------------------- # Core guard: date must not be in the past # --------------------------------------------------------------------------- def _assert_not_past(slot_date: date, operation: str, *, today: Optional[date] = None) -> None: """Raise :class:`ImmutableSlotError` if *slot_date* is before *today*. ``today`` defaults to ``date.today()`` when not supplied (allows deterministic testing). """ if today is None: today = date.today() if slot_date < today: raise ImmutableSlotError(slot_date, operation) # --------------------------------------------------------------------------- # Guards for real (materialized) slots # --------------------------------------------------------------------------- def guard_edit_real_slot( db: Session, slot: TimeSlot, *, today: Optional[date] = None, ) -> None: """Raise if the real *slot* is in the past and cannot be edited.""" _assert_not_past(slot.date, "edit", today=today) def guard_cancel_real_slot( db: Session, slot: TimeSlot, *, today: Optional[date] = None, ) -> None: """Raise if the real *slot* is in the past and cannot be cancelled.""" _assert_not_past(slot.date, "cancel", today=today) # --------------------------------------------------------------------------- # Guards for virtual (plan-generated) slots # --------------------------------------------------------------------------- def guard_edit_virtual_slot( virtual_id: str, *, today: Optional[date] = None, ) -> None: """Raise if the virtual slot identified by *virtual_id* is in the past.""" parsed = parse_virtual_slot_id(virtual_id) if parsed is None: raise ValueError(f"Invalid virtual slot id: {virtual_id!r}") _plan_id, slot_date = parsed _assert_not_past(slot_date, "edit", today=today) def guard_cancel_virtual_slot( virtual_id: str, *, today: Optional[date] = None, ) -> None: """Raise if the virtual slot identified by *virtual_id* is in the past.""" parsed = parse_virtual_slot_id(virtual_id) if parsed is None: raise ValueError(f"Invalid virtual slot id: {virtual_id!r}") _plan_id, slot_date = parsed _assert_not_past(slot_date, "cancel", today=today) # --------------------------------------------------------------------------- # Guard for plan-edit / plan-cancel: no retroactive changes to past slots # --------------------------------------------------------------------------- def get_past_materialized_slot_ids( db: Session, plan_id: int, *, today: Optional[date] = None, ) -> list[int]: """Return IDs of materialized slots for *plan_id* whose date is in the past. Plan-edit and plan-cancel must NOT modify these rows. The caller can use this list to exclude them from bulk updates, or simply to verify that no past data was touched. """ if today is None: today = date.today() rows = ( db.query(TimeSlot.id) .filter( TimeSlot.plan_id == plan_id, TimeSlot.date < today, ) .all() ) return [r[0] for r in rows] def guard_plan_edit_no_past_retroaction( db: Session, plan_id: int, *, today: Optional[date] = None, ) -> list[int]: """Return past materialized slot IDs that must NOT be modified. The caller (plan-edit endpoint) should update only future materialized slots and skip these. This function is informational — it does not raise, because the plan itself *can* be edited; the restriction is that past slots remain untouched. """ return get_past_materialized_slot_ids(db, plan_id, today=today) def guard_plan_cancel_no_past_retroaction( db: Session, plan_id: int, *, today: Optional[date] = None, ) -> list[int]: """Return past materialized slot IDs that must NOT be cancelled. Same semantics as :func:`guard_plan_edit_no_past_retroaction`. When cancelling a plan, future materialized slots may be removed or marked cancelled, but past slots remain untouched. """ return get_past_materialized_slot_ids(db, plan_id, today=today)