Files
HarborForge.Backend/app/services/slot_immutability.py
zhi 4f0e933de3 BE-CAL-007: MinimumWorkload warning rules + BE-CAL-008: past-slot immutability
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.
2026-03-31 04:16:50 +00:00

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)