diff --git a/backend/app/api/endpoints/maintenance_schedules.py b/backend/app/api/endpoints/maintenance_schedules.py index 6b2a953a..581df52d 100644 --- a/backend/app/api/endpoints/maintenance_schedules.py +++ b/backend/app/api/endpoints/maintenance_schedules.py @@ -8,7 +8,7 @@ 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.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 @@ -47,6 +47,7 @@ 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 @@ -108,6 +109,7 @@ async def update_schedule( 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( @@ -121,6 +123,7 @@ async def update_schedule( 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: @@ -130,8 +133,9 @@ async def update_schedule( 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: + # 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: diff --git a/backend/app/api/endpoints/sessions.py b/backend/app/api/endpoints/sessions.py index 0e4911b7..38c3976a 100644 --- a/backend/app/api/endpoints/sessions.py +++ b/backend/app/api/endpoints/sessions.py @@ -528,6 +528,9 @@ async def batch_launch_sessions( if tree.status == 'draft': raise HTTPException(status_code=400, detail="Cannot batch-launch a draft flow") + if not current_user.is_super_admin and tree.team_id != current_user.team_id: + raise HTTPException(status_code=403, detail="Access denied") + if tree.tree_type != "maintenance": raise HTTPException(status_code=400, detail="Batch launch is only for maintenance flows") diff --git a/backend/app/api/endpoints/target_lists.py b/backend/app/api/endpoints/target_lists.py index 247c510e..0bfac439 100644 --- a/backend/app/api/endpoints/target_lists.py +++ b/backend/app/api/endpoints/target_lists.py @@ -5,7 +5,7 @@ from fastapi import APIRouter, Depends, HTTPException, status from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession -from app.api.deps import get_current_active_user, get_db +from app.api.deps import get_current_active_user, get_db, require_engineer_or_admin from app.models.target_list import TargetList from app.models.user import User from app.schemas.target_list import TargetListCreate, TargetListUpdate, TargetListResponse @@ -34,6 +34,7 @@ async def create_target_list( data: TargetListCreate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), ): """Create a new target list for the current team.""" if not current_user.team_id: @@ -75,6 +76,7 @@ async def update_target_list( data: TargetListUpdate, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), ): result = await db.execute( select(TargetList).where( @@ -102,6 +104,7 @@ async def delete_target_list( list_id: UUID, current_user: Annotated[User, Depends(get_current_active_user)], db: Annotated[AsyncSession, Depends(get_db)], + _: None = Depends(require_engineer_or_admin), ): result = await db.execute( select(TargetList).where( diff --git a/backend/app/core/scheduler.py b/backend/app/core/scheduler.py index 17692ad5..3370ce24 100644 --- a/backend/app/core/scheduler.py +++ b/backend/app/core/scheduler.py @@ -4,6 +4,8 @@ import uuid from datetime import datetime, timezone from apscheduler.schedulers.asyncio import AsyncIOScheduler +from apscheduler.schedulers.base import SchedulerNotRunningError +from apscheduler.jobstores.base import JobLookupError from apscheduler.triggers.cron import CronTrigger import pytz from sqlalchemy import select @@ -135,5 +137,8 @@ def unregister_schedule(schedule_id: str) -> None: """Remove a schedule from APScheduler.""" job_id = f"maintenance_{schedule_id}" if scheduler.get_job(job_id): - scheduler.remove_job(job_id) - logger.info(f"Unregistered schedule {schedule_id}") + try: + scheduler.remove_job(job_id) + logger.info(f"Unregistered schedule {schedule_id}") + except (SchedulerNotRunningError, JobLookupError): + logger.warning(f"Could not remove job {job_id}: scheduler not running or job already removed") diff --git a/frontend/src/pages/MaintenanceFlowDetailPage.tsx b/frontend/src/pages/MaintenanceFlowDetailPage.tsx index 2cb6df10..c58ebcef 100644 --- a/frontend/src/pages/MaintenanceFlowDetailPage.tsx +++ b/frontend/src/pages/MaintenanceFlowDetailPage.tsx @@ -69,7 +69,7 @@ export default function MaintenanceFlowDetailPage() { // Group sessions by batch_id for run history const batchMap = new Map() for (const s of recentSessions) { - const key = (s as Session & { batch_id?: string }).batch_id ?? s.id + const key = s.batch_id ?? s.id const existing = batchMap.get(key) ?? [] batchMap.set(key, [...existing, s]) } diff --git a/frontend/src/types/session.ts b/frontend/src/types/session.ts index d608afc3..f621159c 100644 --- a/frontend/src/types/session.ts +++ b/frontend/src/types/session.ts @@ -60,6 +60,8 @@ export interface Session { scratchpad: string next_steps: string session_variables: Record + batch_id?: string + target_label?: string } export interface SessionCreate {