feat: add maintenance_schedules table, schema, and CRUD endpoints

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
chihlasm
2026-02-17 13:20:55 -05:00
parent 5a3af9c87e
commit 25cc16da3a
8 changed files with 343 additions and 0 deletions

View File

@@ -0,0 +1,44 @@
"""add maintenance_schedules table
Revision ID: 0fd2a90a9c2c
Revises: 6e8128ef2aa8
Create Date: 2026-02-17
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '0fd2a90a9c2c'
down_revision = '6e8128ef2aa8'
branch_labels = None
depends_on = None
def upgrade() -> None:
op.create_table(
'maintenance_schedules',
sa.Column('id', postgresql.UUID(as_uuid=True), primary_key=True),
sa.Column('tree_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('trees.id', ondelete='CASCADE'), nullable=False),
sa.Column('created_by', postgresql.UUID(as_uuid=True),
sa.ForeignKey('users.id', ondelete='SET NULL'), nullable=True),
sa.Column('cron_expression', sa.String(100), nullable=False),
sa.Column('timezone', sa.String(100), nullable=False, server_default='UTC'),
sa.Column('target_list_id', postgresql.UUID(as_uuid=True),
sa.ForeignKey('target_lists.id', ondelete='SET NULL'), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('next_run_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('last_run_at', sa.DateTime(timezone=True), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
sa.Column('updated_at', sa.DateTime(timezone=True), nullable=False),
)
op.create_index('ix_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id'])
op.create_unique_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', ['tree_id'])
def downgrade() -> None:
op.drop_constraint('uq_maintenance_schedules_tree_id', 'maintenance_schedules', type_='unique')
op.drop_index('ix_maintenance_schedules_tree_id', table_name='maintenance_schedules')
op.drop_table('maintenance_schedules')

View File

@@ -0,0 +1,115 @@
"""Maintenance schedule CRUD endpoints."""
from typing import Annotated
from uuid import UUID
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from croniter import croniter
import pytz
from app.api.deps import get_current_active_user, get_db
from app.models.maintenance_schedule import MaintenanceSchedule
from app.models.user import User
from app.schemas.maintenance_schedule import (
MaintenanceScheduleCreate,
MaintenanceScheduleUpdate,
MaintenanceScheduleResponse,
)
router = APIRouter(prefix="/maintenance-schedules", tags=["maintenance-schedules"])
def _compute_next_run(cron_expression: str, tz_name: str) -> datetime:
"""Compute next run time from cron expression, returned as UTC."""
tz = pytz.timezone(tz_name)
now = datetime.now(tz)
cron = croniter(cron_expression, now)
return cron.get_next(datetime).astimezone(timezone.utc)
@router.post("", response_model=MaintenanceScheduleResponse, status_code=201)
async def create_schedule(
data: MaintenanceScheduleCreate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Create a cron schedule for a maintenance flow. One per flow."""
# Check no existing schedule for this tree
existing = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == data.tree_id)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=409, detail="Schedule already exists for this tree")
try:
next_run = _compute_next_run(data.cron_expression, data.timezone)
except (ValueError, KeyError) as e:
raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}")
schedule = MaintenanceSchedule(
tree_id=data.tree_id,
created_by=current_user.id,
cron_expression=data.cron_expression,
timezone=data.timezone,
target_list_id=data.target_list_id,
is_active=True,
next_run_at=next_run,
)
db.add(schedule)
await db.commit()
await db.refresh(schedule)
return schedule
@router.get("/tree/{tree_id}", response_model=MaintenanceScheduleResponse)
async def get_schedule_for_tree(
tree_id: UUID,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Get the schedule for a specific maintenance flow."""
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.tree_id == tree_id)
)
schedule = result.scalar_one_or_none()
if not schedule:
raise HTTPException(status_code=404, detail="No schedule found for this tree")
return schedule
@router.patch("/{schedule_id}", response_model=MaintenanceScheduleResponse)
async def update_schedule(
schedule_id: UUID,
data: MaintenanceScheduleUpdate,
current_user: Annotated[User, Depends(get_current_active_user)],
db: Annotated[AsyncSession, Depends(get_db)],
):
"""Update a schedule (disable, change cron, change timezone, change target list)."""
result = await db.execute(
select(MaintenanceSchedule).where(MaintenanceSchedule.id == schedule_id)
)
schedule = result.scalar_one_or_none()
if not schedule:
raise HTTPException(status_code=404, detail="Schedule not found")
update_fields = data.model_fields_set
if "cron_expression" in update_fields and data.cron_expression is not None:
schedule.cron_expression = data.cron_expression
if "timezone" in update_fields and data.timezone is not None:
schedule.timezone = data.timezone
if "target_list_id" in update_fields:
schedule.target_list_id = data.target_list_id
if "is_active" in update_fields and data.is_active is not None:
schedule.is_active = data.is_active
# Recompute next_run_at if schedule timing changed
if "cron_expression" in update_fields or "timezone" in update_fields:
try:
schedule.next_run_at = _compute_next_run(schedule.cron_expression, schedule.timezone)
except (ValueError, KeyError) as e:
raise HTTPException(status_code=422, detail=f"Invalid cron expression or timezone: {e}")
await db.commit()
await db.refresh(schedule)
return schedule

View File

@@ -3,6 +3,7 @@ from app.api.endpoints import auth, trees, sessions, invite, categories, tags, f
from app.api.endpoints import admin_dashboard, admin_audit, admin_plan_limits, admin_feature_flags, admin_settings, admin_categories
from app.api.endpoints import ratings, analytics
from app.api.endpoints import target_lists
from app.api.endpoints import maintenance_schedules
api_router = APIRouter()
@@ -30,3 +31,4 @@ api_router.include_router(tree_markdown.router)
api_router.include_router(ratings.router)
api_router.include_router(analytics.router)
api_router.include_router(target_lists.router)
api_router.include_router(maintenance_schedules.router)

View File

@@ -23,6 +23,7 @@ from .feature_flag import FeatureFlag, PlanFeatureDefault, AccountFeatureOverrid
from .platform_setting import PlatformSetting
from .user_pinned_tree import UserPinnedTree
from .target_list import TargetList
from .maintenance_schedule import MaintenanceSchedule
__all__ = [
"User",
@@ -57,4 +58,5 @@ __all__ = [
"PlatformSetting",
"UserPinnedTree",
"TargetList",
"MaintenanceSchedule",
]

View File

@@ -0,0 +1,45 @@
import uuid
from datetime import datetime, timezone
from typing import Optional
from sqlalchemy import String, DateTime, ForeignKey, Boolean, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from sqlalchemy.dialects.postgresql import UUID
from app.core.database import Base
class MaintenanceSchedule(Base):
__tablename__ = "maintenance_schedules"
__table_args__ = (
UniqueConstraint("tree_id", name="uq_maintenance_schedules_tree_id"),
)
id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), primary_key=True, default=uuid.uuid4
)
tree_id: Mapped[uuid.UUID] = mapped_column(
UUID(as_uuid=True), ForeignKey("trees.id", ondelete="CASCADE"),
nullable=False, index=True
)
created_by: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("users.id", ondelete="SET NULL"), nullable=True
)
cron_expression: Mapped[str] = mapped_column(String(100), nullable=False)
timezone: Mapped[str] = mapped_column(String(100), nullable=False, default="UTC")
target_list_id: Mapped[Optional[uuid.UUID]] = mapped_column(
UUID(as_uuid=True), ForeignKey("target_lists.id", ondelete="SET NULL"), nullable=True
)
is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
next_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
last_run_at: Mapped[Optional[datetime]] = mapped_column(
DateTime(timezone=True), nullable=True
)
created_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), default=lambda: datetime.now(timezone.utc)
)
updated_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True),
default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc),
)

View File

@@ -0,0 +1,34 @@
from datetime import datetime
from typing import Optional
from uuid import UUID
from pydantic import BaseModel, Field
class MaintenanceScheduleCreate(BaseModel):
tree_id: UUID
cron_expression: str = Field(..., min_length=9, max_length=100)
timezone: str = Field("UTC", max_length=100)
target_list_id: Optional[UUID] = None
class MaintenanceScheduleUpdate(BaseModel):
cron_expression: Optional[str] = Field(None, min_length=9, max_length=100)
timezone: Optional[str] = Field(None, max_length=100)
target_list_id: Optional[UUID] = None
is_active: Optional[bool] = None
class MaintenanceScheduleResponse(BaseModel):
id: UUID
tree_id: UUID
created_by: Optional[UUID]
cron_expression: str
timezone: str
target_list_id: Optional[UUID]
is_active: bool
next_run_at: Optional[datetime]
last_run_at: Optional[datetime]
created_at: datetime
updated_at: datetime
model_config = {"from_attributes": True}

View File

@@ -30,3 +30,6 @@ resend==2.21.0
# Utilities
python-dotenv==1.0.1
croniter>=2.0.0
pytz>=2024.1
apscheduler>=3.10.4

View File

@@ -0,0 +1,98 @@
"""Tests for maintenance schedule CRUD."""
import pytest
from httpx import AsyncClient
async def _create_maintenance_tree(client, headers):
resp = await client.post(
"/api/v1/trees",
json={
"name": "Scheduled Patch",
"tree_type": "maintenance",
"tree_structure": {
"steps": [
{"id": "s1", "type": "procedure_step", "title": "Step",
"description": "Do it", "content_type": "action"},
{"id": "end", "type": "procedure_end", "title": "Done"},
]
},
},
headers=headers,
)
assert resp.status_code == 201, resp.text
return resp.json()["id"]
@pytest.mark.asyncio
async def test_create_schedule(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.post(
"/api/v1/maintenance-schedules",
json={
"tree_id": tree_id,
"cron_expression": "0 9 15 * *",
"timezone": "America/New_York",
},
headers=auth_headers,
)
assert resp.status_code == 201, resp.text
data = resp.json()
assert data["cron_expression"] == "0 9 15 * *"
assert data["timezone"] == "America/New_York"
assert data["is_active"] is True
assert data["next_run_at"] is not None
@pytest.mark.asyncio
async def test_duplicate_schedule_rejected(client: AsyncClient, auth_headers: dict):
"""Cannot create two schedules for the same tree."""
tree_id = await _create_maintenance_tree(client, auth_headers)
await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
resp = await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 6 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
assert resp.status_code == 409
@pytest.mark.asyncio
async def test_get_schedule_for_tree(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 0 1 * *", "timezone": "UTC"},
headers=auth_headers,
)
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers)
assert resp.status_code == 200
assert resp.json()["cron_expression"] == "0 0 1 * *"
@pytest.mark.asyncio
async def test_disable_schedule(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
create = await client.post(
"/api/v1/maintenance-schedules",
json={"tree_id": tree_id, "cron_expression": "0 6 * * 1", "timezone": "UTC"},
headers=auth_headers,
)
sched_id = create.json()["id"]
resp = await client.patch(
f"/api/v1/maintenance-schedules/{sched_id}",
json={"is_active": False},
headers=auth_headers,
)
assert resp.status_code == 200
assert resp.json()["is_active"] is False
@pytest.mark.asyncio
async def test_get_schedule_not_found(client: AsyncClient, auth_headers: dict):
tree_id = await _create_maintenance_tree(client, auth_headers)
resp = await client.get(f"/api/v1/maintenance-schedules/tree/{tree_id}", headers=auth_headers)
assert resp.status_code == 404