Files
resolutionflow/backend/app/api/endpoints/maintenance_schedules.py
2026-02-17 13:42:23 -05:00

139 lines
5.3 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)
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)
return schedule