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:
38
app/main.py
38
app/main.py
@@ -78,6 +78,7 @@ from app.api.routers.milestone_actions import router as milestone_actions_router
|
||||
from app.api.routers.meetings import router as meetings_router
|
||||
from app.api.routers.essentials import router as essentials_router
|
||||
from app.api.routers.schedule_type import router as schedule_type_router
|
||||
from app.api.routers.schedule_type_special_slot import router as schedule_type_special_slot_router
|
||||
from app.api.routers.calendar import router as calendar_router
|
||||
from app.api.routers.oidc import router as oidc_router
|
||||
|
||||
@@ -98,6 +99,7 @@ app.include_router(milestone_actions_router)
|
||||
app.include_router(meetings_router)
|
||||
app.include_router(essentials_router)
|
||||
app.include_router(schedule_type_router)
|
||||
app.include_router(schedule_type_special_slot_router)
|
||||
app.include_router(calendar_router)
|
||||
|
||||
|
||||
@@ -397,6 +399,40 @@ def _migrate_schema():
|
||||
if _has_table(db, "agents") and not _has_column(db, "agents", "schedule_type_id"):
|
||||
db.execute(text("ALTER TABLE agents ADD COLUMN schedule_type_id INTEGER NULL"))
|
||||
|
||||
# --- schedule_types: add maintenance_from / maintenance_to ---
|
||||
# Default 8:00–9:00 UTC for existing rows; the 1-hour-window
|
||||
# invariant is enforced at the schema level for any NEW rows by
|
||||
# the pydantic ScheduleTypeCreate validator.
|
||||
if _has_table(db, "schedule_types"):
|
||||
if not _has_column(db, "schedule_types", "maintenance_from"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE schedule_types ADD COLUMN maintenance_from INT NOT NULL DEFAULT 8"
|
||||
))
|
||||
if not _has_column(db, "schedule_types", "maintenance_to"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE schedule_types ADD COLUMN maintenance_to INT NOT NULL DEFAULT 9"
|
||||
))
|
||||
|
||||
# --- time_slots: admin-locked + special_slot pointer ---
|
||||
if _has_table(db, "time_slots"):
|
||||
if not _has_column(db, "time_slots", "is_admin_locked"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE time_slots ADD COLUMN is_admin_locked TINYINT(1) NOT NULL DEFAULT 0"
|
||||
))
|
||||
if not _has_column(db, "time_slots", "special_slot_id"):
|
||||
db.execute(text(
|
||||
"ALTER TABLE time_slots ADD COLUMN special_slot_id INTEGER NULL"
|
||||
))
|
||||
# Index for the materialiser's idempotency lookup
|
||||
db.execute(text(
|
||||
"CREATE INDEX idx_time_slots_special_slot_id ON time_slots (special_slot_id)"
|
||||
))
|
||||
|
||||
# --- schedule_type_special_slots: create-table is handled by
|
||||
# Base.metadata.create_all on first boot; no migration needed here
|
||||
# because there is no legacy table to evolve. Future schema bumps
|
||||
# to that table go in this block.
|
||||
|
||||
db.commit()
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
@@ -431,7 +467,7 @@ def _sync_default_user_roles(db):
|
||||
@app.on_event("startup")
|
||||
def startup():
|
||||
from app.core.config import Base, engine, SessionLocal
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, oidc_settings
|
||||
from app.models import models, webhook, apikey, activity, milestone, notification, worklog, monitor, role_permission, task, support, meeting, proposal, propose, essential, agent, calendar, minimum_workload, schedule_type, schedule_type_special_slot, oidc_settings
|
||||
Base.metadata.create_all(bind=engine)
|
||||
_migrate_schema()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user