feat: add maintenance_schedules table, schema, and CRUD endpoints
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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')
|
||||
115
backend/app/api/endpoints/maintenance_schedules.py
Normal file
115
backend/app/api/endpoints/maintenance_schedules.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
45
backend/app/models/maintenance_schedule.py
Normal file
45
backend/app/models/maintenance_schedule.py
Normal 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),
|
||||
)
|
||||
34
backend/app/schemas/maintenance_schedule.py
Normal file
34
backend/app/schemas/maintenance_schedule.py
Normal 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}
|
||||
@@ -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
|
||||
|
||||
98
backend/tests/test_maintenance_schedules.py
Normal file
98
backend/tests/test_maintenance_schedules.py
Normal 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
|
||||
Reference in New Issue
Block a user