Files
resolutionflow/backend/app/api/endpoints/maintenance_schedules.py
chihlasm 6240d68d09 fix: apply code review security and robustness fixes
- Add require_engineer_or_admin to POST/PUT/DELETE in target_lists.py (blocks viewers from write ops)
- Add require_engineer_or_admin to POST/PATCH in maintenance_schedules.py (blocks viewers from write ops)
- Add team ownership guard in batch_launch_sessions after active/published checks (Fix 2)
- Wrap scheduler.remove_job in try/except for SchedulerNotRunningError and JobLookupError (Fix 3)
- Recompute next_run_at when is_active flips to True, capturing was_active before update (Fix 4)
- Add optional batch_id and target_label fields to Session type; remove unsafe cast in MaintenanceFlowDetailPage.tsx (Fix 5)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2026-02-17 16:15:19 -05:00

154 lines
5.9 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, require_engineer_or_admin
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)],
_: None = Depends(require_engineer_or_admin),
):
"""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)],
_: None = Depends(require_engineer_or_admin),
):
"""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
was_active = schedule.is_active
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 or schedule is being re-activated
reactivating = "is_active" in update_fields and data.is_active is True and not was_active
if "cron_expression" in update_fields or "timezone" in update_fields or reactivating:
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