"""Scheduled backup management.""" from __future__ import annotations from fastapi import APIRouter, Depends, HTTPException, Request from sqlmodel import Session, select from auth import require_admin from database import get_session from models.agent import Agent from models.backup_destination import BackupDestination from models.backup_schedule import ( FREQUENCIES, BackupSchedule, ScheduleCreate, ScheduleRead, ScheduleUpdate, ) from models.stack import Stack from models.user import User from services import audit_service, schedule_service router = APIRouter(prefix="/api/backups/schedules", tags=["backups"]) def _ip(request: Request) -> str: return request.client.host if request.client else "unknown" def _to_read(session: Session, s: BackupSchedule) -> ScheduleRead: dest = session.get(BackupDestination, s.destination_id) agent = session.get(Agent, s.agent_id) if s.agent_id is not None else None return ScheduleRead( id=s.id, stack_id=s.stack_id, destination_id=s.destination_id, destination_name=dest.name if dest else None, agent_id=s.agent_id, agent_name=agent.name if agent else None, frequency=s.frequency, hour=s.hour, minute=s.minute, weekday=s.weekday, include_volumes=s.include_volumes, stop_first=s.stop_first, keep=s.keep, enabled=s.enabled, last_run=s.last_run, last_status=s.last_status, next_run=s.next_run, created_at=s.created_at, ) def _get_or_404(session: Session, schedule_id: int) -> BackupSchedule: s = session.get(BackupSchedule, schedule_id) if not s: raise HTTPException(status_code=404, detail=f"Schedule {schedule_id} not found") return s def _validate(session: Session, schedule: BackupSchedule) -> None: if schedule.frequency not in FREQUENCIES: raise HTTPException(status_code=400, detail=f"Unknown frequency '{schedule.frequency}'") if not session.get(BackupDestination, schedule.destination_id): raise HTTPException(status_code=404, detail=f"Destination {schedule.destination_id} not found") if schedule.agent_id is not None: # Remote stack: validate the agent exists; the stack is checked at run time. if not session.get(Agent, schedule.agent_id): raise HTTPException(status_code=404, detail=f"Agent {schedule.agent_id} not found") elif not session.get(Stack, schedule.stack_id): raise HTTPException(status_code=404, detail=f"Stack '{schedule.stack_id}' not found") @router.get("", response_model=list[ScheduleRead]) def list_schedules( session: Session = Depends(get_session), _user: User = Depends(require_admin), ) -> list[ScheduleRead]: rows = session.exec(select(BackupSchedule).order_by(BackupSchedule.id)).all() return [_to_read(session, s) for s in rows] @router.post("", response_model=ScheduleRead, status_code=201) def create_schedule( body: ScheduleCreate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> ScheduleRead: s = BackupSchedule(**body.model_dump()) _validate(session, s) s.next_run = schedule_service.compute_next_run( s.frequency, s.hour, s.minute, s.weekday, schedule_service._now() ) session.add(s) session.commit() session.refresh(s) audit_service.record( session, user=user.username, action="schedule.create", target=s.stack_id, detail=f"{s.frequency} → dest {s.destination_id}", ip=_ip(request), ) return _to_read(session, s) @router.put("/{schedule_id}", response_model=ScheduleRead) def update_schedule( schedule_id: int, body: ScheduleUpdate, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> ScheduleRead: s = _get_or_404(session, schedule_id) data = body.model_dump(exclude_unset=True) for k, v in data.items(): setattr(s, k, v) _validate(session, s) # Recompute next run when timing fields change. if {"frequency", "hour", "minute", "weekday"} & set(data) or s.next_run is None: s.next_run = schedule_service.compute_next_run( s.frequency, s.hour, s.minute, s.weekday, schedule_service._now() ) session.add(s) session.commit() session.refresh(s) audit_service.record( session, user=user.username, action="schedule.update", target=s.stack_id, ip=_ip(request), ) return _to_read(session, s) @router.delete("/{schedule_id}") def delete_schedule( schedule_id: int, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: s = _get_or_404(session, schedule_id) stack_id = s.stack_id session.delete(s) session.commit() audit_service.record( session, user=user.username, action="schedule.delete", target=stack_id, ip=_ip(request), ) return {"ok": True} @router.post("/{schedule_id}/run") async def run_now( schedule_id: int, request: Request, session: Session = Depends(get_session), user: User = Depends(require_admin), ) -> dict: s = _get_or_404(session, schedule_id) result = await schedule_service.run_schedule(session, s) audit_service.record( session, user=user.username, action="schedule.run", target=s.stack_id, detail="ok" if result.get("ok") else result.get("error", "error"), ip=_ip(request), ) return result