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.
This commit is contained in:
@@ -1,15 +1,20 @@
|
||||
"""MinimumWorkload service — CRUD and validation helpers.
|
||||
"""MinimumWorkload service — CRUD, workload computation and validation.
|
||||
|
||||
BE-CAL-004: user-level workload config read/write + future validation entry point.
|
||||
BE-CAL-004: user-level workload config read/write.
|
||||
BE-CAL-007: workload warning rules — compute actual scheduled minutes across
|
||||
daily/weekly/monthly/yearly periods and compare against thresholds.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from datetime import date, timedelta
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func as sa_func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.models.calendar import SlotStatus, SlotType, TimeSlot
|
||||
from app.models.minimum_workload import (
|
||||
DEFAULT_WORKLOAD_CONFIG,
|
||||
CATEGORIES,
|
||||
@@ -21,6 +26,18 @@ from app.schemas.calendar import (
|
||||
MinimumWorkloadUpdate,
|
||||
WorkloadWarningItem,
|
||||
)
|
||||
from app.services.plan_slot import get_virtual_slots_for_date
|
||||
|
||||
|
||||
# Slot types that map to workload categories. "system" is excluded.
|
||||
_SLOT_TYPE_TO_CATEGORY = {
|
||||
SlotType.WORK: "work",
|
||||
SlotType.ON_CALL: "on_call",
|
||||
SlotType.ENTERTAINMENT: "entertainment",
|
||||
}
|
||||
|
||||
# Statuses that should NOT count towards workload (cancelled / failed slots).
|
||||
_EXCLUDED_STATUSES = {SlotStatus.SKIPPED, SlotStatus.ABORTED}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -96,7 +113,146 @@ def replace_workload_config(
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Validation entry point (BE-CAL-007 will flesh this out)
|
||||
# Workload computation (BE-CAL-007)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _date_range_for_period(
|
||||
period: str,
|
||||
reference_date: date,
|
||||
) -> tuple[date, date]:
|
||||
"""Return inclusive ``(start, end)`` date bounds for *period* containing *reference_date*.
|
||||
|
||||
- daily → just the reference date itself
|
||||
- weekly → ISO week (Mon–Sun) containing the reference date
|
||||
- monthly → calendar month containing the reference date
|
||||
- yearly → calendar year containing the reference date
|
||||
"""
|
||||
if period == "daily":
|
||||
return reference_date, reference_date
|
||||
|
||||
if period == "weekly":
|
||||
# ISO weekday: Monday=1 … Sunday=7
|
||||
start = reference_date - timedelta(days=reference_date.weekday()) # Monday
|
||||
end = start + timedelta(days=6) # Sunday
|
||||
return start, end
|
||||
|
||||
if period == "monthly":
|
||||
start = reference_date.replace(day=1)
|
||||
# Last day of month
|
||||
if reference_date.month == 12:
|
||||
end = reference_date.replace(month=12, day=31)
|
||||
else:
|
||||
end = reference_date.replace(month=reference_date.month + 1, day=1) - timedelta(days=1)
|
||||
return start, end
|
||||
|
||||
if period == "yearly":
|
||||
start = reference_date.replace(month=1, day=1)
|
||||
end = reference_date.replace(month=12, day=31)
|
||||
return start, end
|
||||
|
||||
raise ValueError(f"Unknown period: {period}")
|
||||
|
||||
|
||||
def _sum_real_slots(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> dict[str, int]:
|
||||
"""Sum ``estimated_duration`` of real (materialized) slots by category.
|
||||
|
||||
Returns ``{"work": N, "on_call": N, "entertainment": N}`` with minutes.
|
||||
Slots with status in ``_EXCLUDED_STATUSES`` or ``slot_type=system`` are skipped.
|
||||
"""
|
||||
excluded = [s.value for s in _EXCLUDED_STATUSES]
|
||||
|
||||
rows = (
|
||||
db.query(
|
||||
TimeSlot.slot_type,
|
||||
sa_func.coalesce(sa_func.sum(TimeSlot.estimated_duration), 0),
|
||||
)
|
||||
.filter(
|
||||
TimeSlot.user_id == user_id,
|
||||
TimeSlot.date >= start_date,
|
||||
TimeSlot.date <= end_date,
|
||||
TimeSlot.status.notin_(excluded),
|
||||
TimeSlot.slot_type != SlotType.SYSTEM.value,
|
||||
)
|
||||
.group_by(TimeSlot.slot_type)
|
||||
.all()
|
||||
)
|
||||
|
||||
totals: dict[str, int] = {"work": 0, "on_call": 0, "entertainment": 0}
|
||||
for slot_type_val, total in rows:
|
||||
# slot_type_val may be an enum or a raw string
|
||||
if hasattr(slot_type_val, "value"):
|
||||
slot_type_val = slot_type_val.value
|
||||
cat = _SLOT_TYPE_TO_CATEGORY.get(SlotType(slot_type_val))
|
||||
if cat:
|
||||
totals[cat] += int(total)
|
||||
return totals
|
||||
|
||||
|
||||
def _sum_virtual_slots(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
start_date: date,
|
||||
end_date: date,
|
||||
) -> dict[str, int]:
|
||||
"""Sum ``estimated_duration`` of virtual (plan-generated, not-yet-materialized)
|
||||
slots by category across a date range.
|
||||
|
||||
Iterates day by day — acceptable because periods are at most a year and
|
||||
the function only queries plans once per day.
|
||||
"""
|
||||
totals: dict[str, int] = {"work": 0, "on_call": 0, "entertainment": 0}
|
||||
current = start_date
|
||||
while current <= end_date:
|
||||
for vs in get_virtual_slots_for_date(db, user_id, current):
|
||||
slot_type = vs["slot_type"]
|
||||
if hasattr(slot_type, "value"):
|
||||
slot_type = slot_type.value
|
||||
cat = _SLOT_TYPE_TO_CATEGORY.get(SlotType(slot_type))
|
||||
if cat:
|
||||
totals[cat] += vs["estimated_duration"]
|
||||
current += timedelta(days=1)
|
||||
return totals
|
||||
|
||||
|
||||
def compute_scheduled_minutes(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
reference_date: date,
|
||||
) -> dict[str, dict[str, int]]:
|
||||
"""Compute total scheduled minutes for each period containing *reference_date*.
|
||||
|
||||
Returns the canonical shape consumed by :func:`check_workload_warnings`::
|
||||
|
||||
{
|
||||
"daily": {"work": N, "on_call": N, "entertainment": N},
|
||||
"weekly": { ... },
|
||||
"monthly": { ... },
|
||||
"yearly": { ... },
|
||||
}
|
||||
|
||||
Includes both real (materialized) and virtual (plan-generated) slots.
|
||||
"""
|
||||
result: dict[str, dict[str, int]] = {}
|
||||
|
||||
for period in PERIODS:
|
||||
start, end = _date_range_for_period(period, reference_date)
|
||||
real = _sum_real_slots(db, user_id, start, end)
|
||||
virtual = _sum_virtual_slots(db, user_id, start, end)
|
||||
result[period] = {
|
||||
cat: real.get(cat, 0) + virtual.get(cat, 0)
|
||||
for cat in CATEGORIES
|
||||
}
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Warning comparison
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def check_workload_warnings(
|
||||
@@ -106,14 +262,12 @@ def check_workload_warnings(
|
||||
) -> list[WorkloadWarningItem]:
|
||||
"""Compare *scheduled_minutes* against the user's configured thresholds.
|
||||
|
||||
``scheduled_minutes`` has the same shape as the config:
|
||||
``scheduled_minutes`` has the same shape as the config::
|
||||
|
||||
{"daily": {"work": N, ...}, "weekly": {...}, ...}
|
||||
|
||||
Returns a list of warnings for every (period, category) where the
|
||||
scheduled total is below the minimum. An empty list means no warnings.
|
||||
|
||||
This is the entry point that BE-CAL-007 and the calendar API endpoints
|
||||
will call.
|
||||
"""
|
||||
config = get_workload_config(db, user_id)
|
||||
warnings: list[WorkloadWarningItem] = []
|
||||
@@ -142,3 +296,23 @@ def check_workload_warnings(
|
||||
))
|
||||
|
||||
return warnings
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# High-level convenience: compute + check in one call (BE-CAL-007)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_workload_warnings_for_date(
|
||||
db: Session,
|
||||
user_id: int,
|
||||
reference_date: date,
|
||||
) -> list[WorkloadWarningItem]:
|
||||
"""One-shot helper: compute scheduled minutes for *reference_date* and
|
||||
return any workload warnings.
|
||||
|
||||
Calendar API endpoints should call this after a create/edit mutation to
|
||||
include warnings in the response. Warnings are advisory — they do NOT
|
||||
prevent the operation.
|
||||
"""
|
||||
scheduled = compute_scheduled_minutes(db, user_id, reference_date)
|
||||
return check_workload_warnings(db, user_id, scheduled)
|
||||
|
||||
171
app/services/slot_immutability.py
Normal file
171
app/services/slot_immutability.py
Normal file
@@ -0,0 +1,171 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user