BE-CAL-007: Workload warning computation (already implemented in prior wave, verified tests pass - 24/24). Computes daily/weekly/monthly/yearly scheduled minutes and compares against user thresholds. Warnings are advisory only. BE-CAL-008: New slot_immutability service with guards for: - Forbid edit/cancel of past real slots (raises ImmutableSlotError) - Forbid edit/cancel of past virtual slots - Plan-edit/plan-cancel helper to identify past materialized slot IDs that must not be retroactively modified Tests: 19/19 passing.
172 lines
5.5 KiB
Python
172 lines
5.5 KiB
Python
"""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)
|