feat: add maintenance_schedules table, schema, and CRUD endpoints
Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
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)
|
||||
|
||||
Reference in New Issue
Block a user