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:
h z
2026-05-22 20:18:21 +01:00
parent e5e81d418d
commit 345e0f3a04
8 changed files with 162 additions and 93 deletions

View File

@@ -1,27 +1,44 @@
"""Schemas for ScheduleType CRUD."""
"""Schemas for ScheduleType CRUD.
All `*_from` / `*_to` values are **minutes since UTC midnight** (0-1439).
A maintenance window of variable length is allowed (1-180 minutes,
handles 23→0 wrap).
"""
from pydantic import BaseModel, Field, model_validator
from typing import Optional
def _validate_maintenance_window(maintenance_from: int, maintenance_to: int) -> None:
"""Maintenance window must be exactly 1 hour (handles 23→0 wrap)."""
expected_to = (maintenance_from + 1) % 24
if maintenance_to != expected_to:
_MAX_MIN = 1440 # 24 * 60 — exclusive upper bound
def _maintenance_duration(maint_from: int, maint_to: int) -> int:
"""Maintenance window length in minutes; treats from==to as 24h (invalid)."""
return (maint_to - maint_from) % _MAX_MIN or _MAX_MIN
def _validate_maintenance_window(maint_from: int, maint_to: int) -> None:
dur = _maintenance_duration(maint_from, maint_to)
if dur < 1 or dur > 180:
raise ValueError(
f"maintenance window must be exactly 1 hour: "
f"expected maintenance_to={expected_to}, got {maintenance_to}"
f"maintenance window duration must be in [1, 180] minutes; "
f"got {dur} (from={maint_from}, to={maint_to})"
)
def _validate_minute_field(name: str, value: int) -> None:
if value < 0 or value >= _MAX_MIN:
raise ValueError(f"{name} must be in [0, {_MAX_MIN}); got {value}")
class ScheduleTypeCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
work_from: int = Field(..., ge=0, le=23)
work_to: int = Field(..., ge=0, le=23)
entertainment_from: int = Field(..., ge=0, le=23)
entertainment_to: int = Field(..., ge=0, le=23)
maintenance_from: int = Field(8, ge=0, le=23, description="Maintenance window start hour UTC (default 8)")
maintenance_to: int = Field(9, ge=0, le=23, description="Maintenance window end hour UTC; must equal (maintenance_from+1) % 24")
work_from: int = Field(..., ge=0, lt=_MAX_MIN, description="Work start (minutes since UTC midnight, 0-1439)")
work_to: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_from: int = Field(..., ge=0, lt=_MAX_MIN)
entertainment_to: int = Field(..., ge=0, lt=_MAX_MIN)
maintenance_from: int = Field(480, ge=0, lt=_MAX_MIN, description="Maintenance start (default 480 = 8:00 UTC)")
maintenance_to: int = Field(540, ge=0, lt=_MAX_MIN, description="Maintenance end; (to-from) mod 1440 in [1,180]")
@model_validator(mode="after")
def _check_maintenance(self):
@@ -31,12 +48,12 @@ class ScheduleTypeCreate(BaseModel):
class ScheduleTypeUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
work_from: Optional[int] = Field(None, ge=0, le=23)
work_to: Optional[int] = Field(None, ge=0, le=23)
entertainment_from: Optional[int] = Field(None, ge=0, le=23)
entertainment_to: Optional[int] = Field(None, ge=0, le=23)
maintenance_from: Optional[int] = Field(None, ge=0, le=23)
maintenance_to: Optional[int] = Field(None, ge=0, le=23)
work_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
work_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
entertainment_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_from: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
maintenance_to: Optional[int] = Field(None, ge=0, lt=_MAX_MIN)
@model_validator(mode="after")
def _check_maintenance(self):
@@ -56,6 +73,7 @@ class ScheduleTypeResponse(BaseModel):
entertainment_to: int
maintenance_from: int
maintenance_to: int
maintenance_duration_minutes: Optional[int] = None # derived; populated by router
class Config:
from_attributes = True

View File

@@ -9,8 +9,8 @@ from pydantic import BaseModel, Field
class SpecialSlotCreate(BaseModel):
name: str = Field(..., min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: int = Field(0, ge=0, le=59, description="Minute offset (0-59) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=60, description="Duration in minutes; must fit inside the 1-hour maintenance window")
minute_in_window: int = Field(0, ge=0, le=179, description="Minute offset (0-179) inside the schedule_type maintenance window")
estimated_duration: int = Field(15, ge=1, le=180, description="Duration in minutes; must fit inside the maintenance window (1-180min)")
priority: int = Field(50, ge=0, le=99)
event_data: Optional[dict[str, Any]] = Field(None, description="JSON payload merged into every materialised slot's event_data")
is_active: bool = True
@@ -19,8 +19,8 @@ class SpecialSlotCreate(BaseModel):
class SpecialSlotUpdate(BaseModel):
name: Optional[str] = Field(None, min_length=1, max_length=64)
description: Optional[str] = Field(None, max_length=512)
minute_in_window: Optional[int] = Field(None, ge=0, le=59)
estimated_duration: Optional[int] = Field(None, ge=1, le=60)
minute_in_window: Optional[int] = Field(None, ge=0, le=179)
estimated_duration: Optional[int] = Field(None, ge=1, le=180)
priority: Optional[int] = Field(None, ge=0, le=99)
event_data: Optional[dict[str, Any]] = None
is_active: Optional[bool] = None