Files
resolutionflow/backend/app/api/endpoints/maintenance_schedules.py
chihlasm 5abff028bc feat: APScheduler integration for maintenance flow auto-session creation
- Add backend/app/core/scheduler.py with AsyncIOScheduler, CronTrigger-based
  job registration, and _fire_maintenance_schedule to create batch sessions
- Wire scheduler.start()/load_all_schedules()/shutdown() into main.py lifespan
- Call register_schedule() in create_schedule endpoint after commit
- Call register_schedule()/unregister_schedule() in update_schedule based on is_active
- Add TreeShare to models/__init__.py so all SQLAlchemy mapper relationships
  resolve before ORM queries in the scheduler context

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 14:21:29 -05:00

150 lines
5.6 KiB
Python

"""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.tree import Tree
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)
async def _get_tree_or_403(tree_id: UUID, current_user: User, db: AsyncSession) -> "Tree":
"""Fetch tree and verify the current user's team owns it."""
result = await db.execute(select(Tree).where(Tree.id == tree_id))
tree = result.scalar_one_or_none()
if not tree:
raise HTTPException(status_code=404, detail="Tree not found")
# Super admins can access any tree; regular users must be on the same team
if not getattr(current_user, 'is_super_admin', False):
if tree.team_id != current_user.team_id:
raise HTTPException(status_code=403, detail="Access denied")
return tree
@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."""
# Verify user's team owns the tree
await _get_tree_or_403(data.tree_id, current_user, db)
# 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)
from app.core.scheduler import register_schedule
register_schedule(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."""
# Verify user's team owns the tree before returning schedule data
await _get_tree_or_403(tree_id, current_user, db)
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")
# Verify user's team owns the tree this schedule belongs to
await _get_tree_or_403(schedule.tree_id, current_user, db)
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)
from app.core.scheduler import register_schedule, unregister_schedule
if schedule.is_active:
register_schedule(schedule)
else:
unregister_schedule(str(schedule.id))
return schedule