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,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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user