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:
@@ -21,6 +21,11 @@ from app.core.config import get_db
|
||||
from app.models.calendar import SchedulePlan, SlotStatus, SlotType, TimeSlot
|
||||
from app.models.models import User
|
||||
from app.models.agent import Agent, AgentStatus, ExhaustReason
|
||||
from app.models.schedule_type import ScheduleType
|
||||
from app.services.special_slot_materialiser import (
|
||||
materialise_special_slots_for_claw,
|
||||
materialise_special_slots_for_user,
|
||||
)
|
||||
from app.schemas.calendar import (
|
||||
AgentHeartbeatResponse,
|
||||
AgentStatusUpdateRequest,
|
||||
@@ -143,6 +148,8 @@ def _slot_to_response(slot: TimeSlot) -> TimeSlotResponse:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -168,6 +175,46 @@ def create_slot(
|
||||
"""
|
||||
target_date = payload.date or date_type.today()
|
||||
|
||||
# --- Maintenance-window guard ---
|
||||
# Non-`system` slots may not be placed inside the schedule_type's
|
||||
# 1-hour maintenance window. The window is admin-territory, reserved
|
||||
# for materialised special slots from `schedule_type_special_slots`.
|
||||
# `system` slot_type is itself reserved server-side (the materialiser
|
||||
# is the only legitimate caller) — refuse it here outright so the
|
||||
# public API cannot manufacture a fake admin-locked slot.
|
||||
if payload.slot_type == SlotType.SYSTEM:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
"slot_type='system' is reserved for schedule_type special slots "
|
||||
"and cannot be created via this endpoint"
|
||||
),
|
||||
)
|
||||
_agent_for_user = (
|
||||
db.query(Agent).filter(Agent.user_id == current_user.id).first()
|
||||
)
|
||||
if _agent_for_user and _agent_for_user.schedule_type_id:
|
||||
st = (
|
||||
db.query(ScheduleType)
|
||||
.filter(ScheduleType.id == _agent_for_user.schedule_type_id)
|
||||
.first()
|
||||
)
|
||||
if st and _scheduled_inside_window(
|
||||
payload.scheduled_at,
|
||||
payload.estimated_duration,
|
||||
st.maintenance_from,
|
||||
st.maintenance_to,
|
||||
):
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail=(
|
||||
f"slot at {payload.scheduled_at} duration {payload.estimated_duration}min "
|
||||
f"intersects the maintenance window "
|
||||
f"{st.maintenance_from:02d}:00-{st.maintenance_to:02d}:00 UTC of "
|
||||
f"schedule_type '{st.name}' — that window is admin-reserved"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Overlap check (hard reject) ---
|
||||
conflicts = check_overlap_for_create(
|
||||
db,
|
||||
@@ -236,6 +283,8 @@ def _real_slot_to_item(slot: TimeSlot) -> CalendarSlotItem:
|
||||
priority=slot.priority,
|
||||
status=slot.status.value if hasattr(slot.status, "value") else str(slot.status),
|
||||
plan_id=slot.plan_id,
|
||||
is_admin_locked=bool(getattr(slot, "is_admin_locked", False)),
|
||||
special_slot_id=getattr(slot, "special_slot_id", None),
|
||||
created_at=slot.created_at,
|
||||
updated_at=slot.updated_at,
|
||||
)
|
||||
@@ -289,7 +338,48 @@ def _require_agent(db: Session, agent_id: str, claw_identifier: str) -> Agent:
|
||||
return agent
|
||||
|
||||
|
||||
def _scheduled_inside_window(
|
||||
scheduled_at,
|
||||
estimated_duration_minutes: int,
|
||||
window_from_hour: int,
|
||||
window_to_hour: int,
|
||||
) -> bool:
|
||||
"""True if [scheduled_at, scheduled_at+duration] intersects [from:00, to:00].
|
||||
|
||||
Handles the 23→0 wrap case (window straddles UTC midnight).
|
||||
"""
|
||||
start_min = scheduled_at.hour * 60 + scheduled_at.minute
|
||||
end_min = start_min + max(estimated_duration_minutes, 1)
|
||||
win_start_min = window_from_hour * 60
|
||||
win_end_min = window_to_hour * 60
|
||||
if win_end_min > win_start_min:
|
||||
# normal same-day window
|
||||
return start_min < win_end_min and end_min > win_start_min
|
||||
# wrap-around: window = [from..24:00) ∪ [00:00..to)
|
||||
return (start_min < 24 * 60 and end_min > win_start_min) or end_min > win_end_min
|
||||
|
||||
|
||||
# Admin-locked special slots accept only these agent-driven status
|
||||
# transitions; movement / cancellation / arbitrary status edits are
|
||||
# rejected because the schedule_type owner is the source of truth.
|
||||
_ADMIN_LOCKED_ALLOWED_STATUSES = {
|
||||
SlotStatusEnum.ONGOING,
|
||||
SlotStatusEnum.PAUSED,
|
||||
SlotStatusEnum.FINISHED,
|
||||
SlotStatusEnum.ABORTED,
|
||||
}
|
||||
|
||||
|
||||
def _apply_agent_slot_update(slot: TimeSlot, payload: SlotAgentUpdate) -> None:
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
if payload.status not in _ADMIN_LOCKED_ALLOWED_STATUSES:
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (special slot); only "
|
||||
f"ongoing/paused/finished/aborted are allowed via agent-update"
|
||||
),
|
||||
)
|
||||
slot.status = payload.status.value
|
||||
if payload.started_at is not None:
|
||||
slot.started_at = payload.started_at
|
||||
@@ -377,6 +467,12 @@ def sync_schedules(
|
||||
"""
|
||||
today = date_type.today()
|
||||
|
||||
# Materialise today's special slots for every agent on this claw
|
||||
# before reading. This is idempotent — re-runs against an already-
|
||||
# materialised (agent, date, template) are no-ops. Plugin's runSync
|
||||
# picks them up like any other slot via the normal real_slots query.
|
||||
materialise_special_slots_for_claw(db, x_claw_identifier, today, commit=True)
|
||||
|
||||
# Find all agents on this claw instance
|
||||
agents = (
|
||||
db.query(Agent)
|
||||
@@ -514,6 +610,10 @@ def get_calendar_day(
|
||||
"""
|
||||
target_date = date or date_type.today()
|
||||
|
||||
# Materialise today's special slots for this user before reading,
|
||||
# so the day-view returns them alongside any user-created slots.
|
||||
materialise_special_slots_for_user(db, current_user.id, target_date, commit=True)
|
||||
|
||||
# 1. Fetch real slots for the day
|
||||
real_slots = (
|
||||
db.query(TimeSlot)
|
||||
@@ -589,6 +689,20 @@ def edit_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
# Special slots materialised from a schedule_type template are
|
||||
# admin-owned; agents may complete/abort/pause/resume via the
|
||||
# plugin-facing agent-update endpoint but cannot edit time/type/
|
||||
# duration/event-data via this user-facing edit endpoint.
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can edit it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_edit_real_slot(db, slot)
|
||||
@@ -756,6 +870,16 @@ def cancel_real_slot(
|
||||
if slot is None:
|
||||
raise HTTPException(status_code=404, detail="Slot not found")
|
||||
|
||||
# --- Admin-locked guard ---
|
||||
if getattr(slot, "is_admin_locked", False):
|
||||
raise HTTPException(
|
||||
status_code=423,
|
||||
detail=(
|
||||
f"slot {slot.id} is admin-locked (materialised from a special "
|
||||
f"slot template); only the schedule_type owner can cancel it"
|
||||
),
|
||||
)
|
||||
|
||||
# --- Past-slot guard ---
|
||||
try:
|
||||
guard_cancel_real_slot(db, slot)
|
||||
|
||||
223
app/api/routers/schedule_type_special_slot.py
Normal file
223
app/api/routers/schedule_type_special_slot.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""Special-slot CRUD for a ScheduleType (admin-only).
|
||||
|
||||
A "special slot" is a recurring slot template tied to a ScheduleType.
|
||||
The system materialises one `time_slots` row per agent on that
|
||||
schedule_type per date, scheduled inside the schedule_type's
|
||||
maintenance window. Materialised rows are `is_admin_locked=true` —
|
||||
agents can complete / abort / pause / resume them but cannot move
|
||||
or cancel them.
|
||||
|
||||
All endpoints require `schedule_type.manage` (admin auto-grants).
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.core.config import get_db
|
||||
from app.api.deps import get_current_user_or_apikey
|
||||
from app.models.models import User
|
||||
from app.models.role_permission import Permission, RolePermission
|
||||
from app.models.schedule_type import ScheduleType
|
||||
from app.models.schedule_type_special_slot import ScheduleTypeSpecialSlot
|
||||
from app.schemas.schedule_type_special_slot import (
|
||||
SpecialSlotCreate,
|
||||
SpecialSlotUpdate,
|
||||
SpecialSlotResponse,
|
||||
)
|
||||
|
||||
|
||||
router = APIRouter(prefix="/schedule-types", tags=["ScheduleTypes"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Permission helpers — mirror schedule_type.py's local helpers so this router
|
||||
# doesn't have to depend on internal symbols of the other router.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _has_permission(db: Session, user: User, permission_name: str) -> bool:
|
||||
if user.is_admin:
|
||||
return True
|
||||
if not user.role_id:
|
||||
return False
|
||||
return (
|
||||
db.query(RolePermission)
|
||||
.join(Permission)
|
||||
.filter(
|
||||
RolePermission.role_id == user.role_id,
|
||||
Permission.name == permission_name,
|
||||
)
|
||||
.first()
|
||||
is not None
|
||||
)
|
||||
|
||||
|
||||
def _require_schedule_manage(db: Session, user: User) -> User:
|
||||
if not _has_permission(db, user, "schedule_type.manage"):
|
||||
raise HTTPException(403, "Permission denied: schedule_type.manage")
|
||||
return user
|
||||
|
||||
|
||||
def _require_schedule_read(db: Session, user: User) -> User:
|
||||
if not _has_permission(db, user, "schedule_type.read"):
|
||||
raise HTTPException(403, "Permission denied: schedule_type.read")
|
||||
return user
|
||||
|
||||
|
||||
def _fetch_schedule_type(db: Session, schedule_type_id: int) -> ScheduleType:
|
||||
st = db.query(ScheduleType).filter(ScheduleType.id == schedule_type_id).first()
|
||||
if not st:
|
||||
raise HTTPException(404, f"ScheduleType {schedule_type_id} not found")
|
||||
return st
|
||||
|
||||
|
||||
def _validate_fits_window(
|
||||
minute_in_window: int,
|
||||
estimated_duration: int,
|
||||
) -> None:
|
||||
"""Reject special slots that wouldn't fit inside the 1-hour window."""
|
||||
if minute_in_window + estimated_duration > 60:
|
||||
raise HTTPException(
|
||||
422,
|
||||
(
|
||||
f"special slot does not fit in maintenance window: "
|
||||
f"minute_in_window={minute_in_window} + "
|
||||
f"estimated_duration={estimated_duration} > 60"
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/{schedule_type_id}/special-slots",
|
||||
response_model=List[SpecialSlotResponse],
|
||||
summary="List special slots for a schedule type",
|
||||
)
|
||||
def list_special_slots(
|
||||
schedule_type_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_schedule_read(db, current_user)
|
||||
_fetch_schedule_type(db, schedule_type_id)
|
||||
return (
|
||||
db.query(ScheduleTypeSpecialSlot)
|
||||
.filter(ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id)
|
||||
.order_by(
|
||||
ScheduleTypeSpecialSlot.minute_in_window.asc(),
|
||||
ScheduleTypeSpecialSlot.id.asc(),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{schedule_type_id}/special-slots",
|
||||
response_model=SpecialSlotResponse,
|
||||
summary="Create a special slot for a schedule type (admin)",
|
||||
)
|
||||
def create_special_slot(
|
||||
schedule_type_id: int,
|
||||
payload: SpecialSlotCreate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
_fetch_schedule_type(db, schedule_type_id)
|
||||
_validate_fits_window(payload.minute_in_window, payload.estimated_duration)
|
||||
|
||||
dup = (
|
||||
db.query(ScheduleTypeSpecialSlot)
|
||||
.filter(
|
||||
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
|
||||
ScheduleTypeSpecialSlot.name == payload.name,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if dup:
|
||||
raise HTTPException(
|
||||
409,
|
||||
f"special slot '{payload.name}' already exists for schedule_type {schedule_type_id}",
|
||||
)
|
||||
|
||||
slot = ScheduleTypeSpecialSlot(
|
||||
schedule_type_id=schedule_type_id,
|
||||
name=payload.name,
|
||||
description=payload.description,
|
||||
minute_in_window=payload.minute_in_window,
|
||||
estimated_duration=payload.estimated_duration,
|
||||
priority=payload.priority,
|
||||
event_data=payload.event_data,
|
||||
is_active=payload.is_active,
|
||||
created_by_user_id=current_user.id,
|
||||
)
|
||||
db.add(slot)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return slot
|
||||
|
||||
|
||||
@router.patch(
|
||||
"/{schedule_type_id}/special-slots/{slot_id}",
|
||||
response_model=SpecialSlotResponse,
|
||||
summary="Update a special slot (admin)",
|
||||
)
|
||||
def update_special_slot(
|
||||
schedule_type_id: int,
|
||||
slot_id: int,
|
||||
payload: SpecialSlotUpdate,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
slot = (
|
||||
db.query(ScheduleTypeSpecialSlot)
|
||||
.filter(
|
||||
ScheduleTypeSpecialSlot.id == slot_id,
|
||||
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not slot:
|
||||
raise HTTPException(404, "Special slot not found")
|
||||
|
||||
update_fields = payload.model_dump(exclude_unset=True)
|
||||
next_min = update_fields.get("minute_in_window", slot.minute_in_window)
|
||||
next_dur = update_fields.get("estimated_duration", slot.estimated_duration)
|
||||
_validate_fits_window(next_min, next_dur)
|
||||
|
||||
for field, value in update_fields.items():
|
||||
setattr(slot, field, value)
|
||||
db.commit()
|
||||
db.refresh(slot)
|
||||
return slot
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{schedule_type_id}/special-slots/{slot_id}",
|
||||
summary="Delete a special slot (admin)",
|
||||
)
|
||||
def delete_special_slot(
|
||||
schedule_type_id: int,
|
||||
slot_id: int,
|
||||
db: Session = Depends(get_db),
|
||||
current_user: User = Depends(get_current_user_or_apikey),
|
||||
):
|
||||
_require_schedule_manage(db, current_user)
|
||||
slot = (
|
||||
db.query(ScheduleTypeSpecialSlot)
|
||||
.filter(
|
||||
ScheduleTypeSpecialSlot.id == slot_id,
|
||||
ScheduleTypeSpecialSlot.schedule_type_id == schedule_type_id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not slot:
|
||||
raise HTTPException(404, "Special slot not found")
|
||||
db.delete(slot)
|
||||
db.commit()
|
||||
return {"ok": True, "deleted": slot_id}
|
||||
Reference in New Issue
Block a user