feat(calendar): maintenance window + schedule_type special slots
## What this adds
1. **Maintenance window on ScheduleType**
- New columns: maintenance_from / maintenance_to (UTC hours, 0-23)
- Invariant: window is exactly 1 hour (validated in pydantic;
maintenance_to must equal (maintenance_from + 1) % 24)
- Default applied via additive migration: 8:00-9:00 UTC for existing
rows so deployments don't crash on first boot
2. **ScheduleTypeSpecialSlot** — admin-managed slot template
- New table schedule_type_special_slots
- Admin (schedule_type.manage) CRUD via
/schedule-types/{id}/special-slots
- Fields: name, description, minute_in_window (0-59 inside the
parent maintenance window), estimated_duration, priority,
event_data (JSON merged into materialised slot), is_active
- Unique constraint (schedule_type_id, name) — name is the stable
human-readable identifier per cohort
3. **Per-agent materialisation**
- New service app/services/special_slot_materialiser.py
- GET /calendar/sync calls materialise_special_slots_for_claw
(idempotent, one row per agent per template per date)
- GET /calendar/day calls materialise_special_slots_for_user
- Materialised rows are slot_type=system, event_type=system_event,
is_admin_locked=true, special_slot_id pointing back to template
- Plugin's runSync picks them up like any other due slot via the
normal real-slots query path
4. **Admin-locked enforcement**
- New TimeSlot columns: is_admin_locked, special_slot_id (FK to
schedule_type_special_slots, ON DELETE SET NULL)
- PATCH /calendar/slots/{id}: refuses any edit on admin-locked
slots (423)
- POST /calendar/slots/{id}/cancel: refuses cancel on admin-locked
(423)
- PATCH /calendar/slots/{id}/agent-update: admin-locked accept only
ongoing/paused/finished/aborted statuses (423 on other transitions)
5. **Maintenance-window guard on slot creation**
- POST /calendar/slots: rejects slot_type=system outright (only
materialiser may create system slots) and rejects any non-system
slot whose [scheduled_at, +duration] intersects the calling
user's schedule_type maintenance window (422). Handles 23->0 wrap
6. **Schema response**
- TimeSlotResponse / CalendarSlotItem now include is_admin_locked
and special_slot_id so clients can render the lock indicator and
trace back to the template
## Migration
Additive only — no destructive changes. Lives in _migrate_schema()
in app/main.py; the new schedule_type_special_slots table is created
by Base.metadata.create_all() on first boot.
🤖 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:
@@ -144,6 +144,8 @@ class TimeSlotResponse(BaseModel):
|
||||
priority: int
|
||||
status: str
|
||||
plan_id: Optional[int] = None
|
||||
is_admin_locked: bool = False
|
||||
special_slot_id: Optional[int] = None
|
||||
created_at: Optional[dt_datetime] = None
|
||||
updated_at: Optional[dt_datetime] = None
|
||||
|
||||
@@ -226,6 +228,8 @@ class CalendarSlotItem(BaseModel):
|
||||
priority: int
|
||||
status: str
|
||||
plan_id: Optional[int] = None
|
||||
is_admin_locked: bool = False
|
||||
special_slot_id: Optional[int] = None
|
||||
created_at: Optional[dt_datetime] = None
|
||||
updated_at: Optional[dt_datetime] = None
|
||||
|
||||
|
||||
@@ -1,15 +1,32 @@
|
||||
"""Schemas for ScheduleType CRUD."""
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
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:
|
||||
raise ValueError(
|
||||
f"maintenance window must be exactly 1 hour: "
|
||||
f"expected maintenance_to={expected_to}, got {maintenance_to}"
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_maintenance(self):
|
||||
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
|
||||
return self
|
||||
|
||||
|
||||
class ScheduleTypeUpdate(BaseModel):
|
||||
@@ -18,6 +35,16 @@ class ScheduleTypeUpdate(BaseModel):
|
||||
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)
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _check_maintenance(self):
|
||||
# Only validate when both fields are present together; partial-
|
||||
# update validation against the merged row happens at apply time.
|
||||
if self.maintenance_from is not None and self.maintenance_to is not None:
|
||||
_validate_maintenance_window(self.maintenance_from, self.maintenance_to)
|
||||
return self
|
||||
|
||||
|
||||
class ScheduleTypeResponse(BaseModel):
|
||||
@@ -27,6 +54,8 @@ class ScheduleTypeResponse(BaseModel):
|
||||
work_to: int
|
||||
entertainment_from: int
|
||||
entertainment_to: int
|
||||
maintenance_from: int
|
||||
maintenance_to: int
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
43
app/schemas/schedule_type_special_slot.py
Normal file
43
app/schemas/schedule_type_special_slot.py
Normal file
@@ -0,0 +1,43 @@
|
||||
"""Schemas for ScheduleTypeSpecialSlot CRUD (admin-only)."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Optional
|
||||
|
||||
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")
|
||||
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
|
||||
|
||||
|
||||
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)
|
||||
priority: Optional[int] = Field(None, ge=0, le=99)
|
||||
event_data: Optional[dict[str, Any]] = None
|
||||
is_active: Optional[bool] = None
|
||||
|
||||
|
||||
class SpecialSlotResponse(BaseModel):
|
||||
id: int
|
||||
schedule_type_id: int
|
||||
name: str
|
||||
description: Optional[str]
|
||||
minute_in_window: int
|
||||
estimated_duration: int
|
||||
priority: int
|
||||
event_data: Optional[dict[str, Any]]
|
||||
is_active: bool
|
||||
created_by_user_id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
Reference in New Issue
Block a user