feat(schedule_type): minute-precision windows + variable maintenance length
Lifts the two hard restrictions in PR #18: * window bounds were `int hour` (0-23) → now `int minutes-since-UTC-midnight` (0-1439) * maintenance window was exactly 1 hour → now any duration in [1, 180] minutes ((maint_to - maint_from) mod 1440) ## Schema migration (additive) `_migrate_schema()` detects legacy "hours" rows (any row where MAX of the 6 window columns is ≤ 23) and multiplies each column by 60 to convert to minutes. Idempotent — post-conversion values are well above 23 so the guard never fires twice. ## Touched surfaces - `models/schedule_type.py` — column comments updated; new `compute_maintenance_duration()` helper (returns 1-1440 min, treats from==to as 1440 which is then rejected by validator) - `schemas/schedule_type.py` — `*_from`/`*_to` upper bound 23 → 1440; `_validate_maintenance_window` accepts 1-180min duration; response includes derived `maintenance_duration_minutes` - `schemas/schedule_type_special_slot.py` — `minute_in_window` max 59→179; `estimated_duration` max 60→180 - `routers/schedule_type.py` — PATCH re-validates merged maintenance pair (partial updates can put the row into an invalid combo the pydantic single-field validator can't catch); `_attach_derived` populates the new response field - `routers/schedule_type_special_slot.py` — `_validate_fits_window` now takes the parent's maintenance duration instead of hard-coded 60 - `routers/calendar.py` — `_scheduled_inside_window` arg renamed hour→min; the maintenance-window guard error message formats HH:MM not HH:00 - `services/special_slot_materialiser.py` — materialised `scheduled_at` derived from `(maint_from_min + tpl.minute_in_window)` with hour/minute split 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,9 +1,17 @@
|
||||
"""ScheduleType model — defines work/entertainment/maintenance time periods.
|
||||
|
||||
Each ScheduleType defines the daily work, entertainment, and maintenance
|
||||
windows. Agents reference a schedule_type to know when they should be
|
||||
working, when they can engage in entertainment, and when the system
|
||||
requires them to surrender control for admin-scheduled special slots.
|
||||
windows for agents who reference this type. All bounds are stored as
|
||||
**minutes-since-UTC-midnight** (0-1439 inclusive) so half-hour and other
|
||||
sub-hour boundaries are exact.
|
||||
|
||||
Maintenance window length is variable (1-180 minutes) and admin-owned;
|
||||
agent slots cannot intersect it (see `app/api/routers/calendar.py`).
|
||||
|
||||
Historical note: pre-PR #21 the columns held *hours* (0-23) and the
|
||||
maintenance window was hard-fixed at exactly 1 hour. The additive
|
||||
migration in `_migrate_schema()` multiplies legacy values by 60 so
|
||||
existing rows convert transparently.
|
||||
"""
|
||||
|
||||
from sqlalchemy import Column, Integer, String, DateTime
|
||||
@@ -26,52 +34,26 @@ class ScheduleType(Base):
|
||||
comment="Human-readable schedule type name (e.g., 'standard', 'night-shift')",
|
||||
)
|
||||
|
||||
work_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Work period start hour (0-23, UTC)",
|
||||
)
|
||||
# Minutes since UTC midnight, 0-1439 inclusive.
|
||||
work_from = Column(Integer, nullable=False, comment="Work period start (minutes since UTC midnight)")
|
||||
work_to = Column(Integer, nullable=False, comment="Work period end (minutes since UTC midnight)")
|
||||
|
||||
work_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Work period end hour (0-23, UTC)",
|
||||
)
|
||||
entertainment_from = Column(Integer, nullable=False, comment="Entertainment start (minutes since UTC midnight)")
|
||||
entertainment_to = Column(Integer, nullable=False, comment="Entertainment end (minutes since UTC midnight)")
|
||||
|
||||
entertainment_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Entertainment period start hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
entertainment_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
comment="Entertainment period end hour (0-23, UTC)",
|
||||
)
|
||||
|
||||
# -----------------------------------------------------------------
|
||||
# Maintenance window — every agent on this schedule_type must
|
||||
# surrender work/entertainment slots during this hour. Admin-created
|
||||
# special slots tied to this schedule_type can only be scheduled
|
||||
# inside this window. The window is always exactly 1 hour.
|
||||
#
|
||||
# Default (when columns are added via additive migration to existing
|
||||
# rows) is 8:00–9:00 UTC so deployments stay sane until an operator
|
||||
# picks proper hours per schedule_type.
|
||||
# -----------------------------------------------------------------
|
||||
# Maintenance window — admin-owned, variable length (1-180 min).
|
||||
# Default 8:00–9:00 UTC = 480–540 minutes for existing rows.
|
||||
maintenance_from = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="8",
|
||||
comment="Maintenance window start hour (0-23, UTC). Window is exactly 1h.",
|
||||
server_default="480",
|
||||
comment="Maintenance start (minutes since UTC midnight, default 480 = 8:00 UTC).",
|
||||
)
|
||||
|
||||
maintenance_to = Column(
|
||||
Integer,
|
||||
nullable=False,
|
||||
server_default="9",
|
||||
comment="Maintenance window end hour (0-23, UTC). Must equal (maintenance_from + 1) % 24.",
|
||||
server_default="540",
|
||||
comment="Maintenance end (minutes since UTC midnight, default 540 = 9:00 UTC). Duration ((to-from) mod 1440) must be in [1, 180].",
|
||||
)
|
||||
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
@@ -83,3 +65,19 @@ class ScheduleType(Base):
|
||||
back_populates="schedule_type",
|
||||
cascade="all, delete-orphan",
|
||||
)
|
||||
|
||||
# ---------------------------------------------------------------
|
||||
# Convenience methods used by the API layer + materialiser.
|
||||
# ---------------------------------------------------------------
|
||||
|
||||
def compute_maintenance_duration(self) -> int:
|
||||
"""Maintenance window length in minutes (handles 23→0 wrap)."""
|
||||
return (self.maintenance_to - self.maintenance_from) % 1440 or 1440
|
||||
|
||||
def window_contains(self, start_min: int, end_min: int, win_from: int, win_to: int) -> bool:
|
||||
"""True if [start_min, end_min) intersects [win_from, win_to) (handles wrap)."""
|
||||
# Normalise into [0, 1440) — same logic as the helper in calendar.py.
|
||||
if win_to > win_from:
|
||||
return start_min < win_to and end_min > win_from
|
||||
# wrap window crosses midnight: [win_from..1440) ∪ [0..win_to)
|
||||
return start_min < win_to or end_min > win_from
|
||||
|
||||
Reference in New Issue
Block a user