From bc5384e63a7694d8641341671baececb39fda739 Mon Sep 17 00:00:00 2001 From: rarebuffalo Date: Thu, 7 May 2026 10:32:53 +0530 Subject: [PATCH] List all scheduled scans --- app/routers/scheduled_scans.py | 150 +++++++++++++++++++++++++++++++++ 1 file changed, 150 insertions(+) create mode 100644 app/routers/scheduled_scans.py diff --git a/app/routers/scheduled_scans.py b/app/routers/scheduled_scans.py new file mode 100644 index 0000000..0ede063 --- /dev/null +++ b/app/routers/scheduled_scans.py @@ -0,0 +1,150 @@ +""" +Scheduled Scans Router +====================== + +CRUD endpoints for managing recurring URL scans. + +POST /scheduled-scans — create a new scheduled scan +GET /scheduled-scans — list all your scheduled scans +PATCH /scheduled-scans/{id}/toggle — pause or resume a scheduled scan +DELETE /scheduled-scans/{id} — delete a scheduled scan + +All endpoints require authentication (JWT). +""" + +from fastapi import APIRouter, Depends, HTTPException, status +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.middleware.auth import get_current_user +from app.models.scheduled_scan import ScheduledScan +from app.models.user import User +from app.schemas.scan import ScheduledScanCreate, ScheduledScanResponse +from app.utils.validators import validate_url + +router = APIRouter(prefix="/scheduled-scans", tags=["scheduled-scans"]) + + +@router.post("", response_model=ScheduledScanResponse, status_code=status.HTTP_201_CREATED) +async def create_scheduled_scan( + data: ScheduledScanCreate, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Register a URL for recurring automated scanning. + + The scheduler checks every hour for scans that are due (past their + daily/weekly window) and runs them automatically. Results are saved + to the scan history and webhooks fire if the score drops. + """ + if data.schedule not in ("daily", "weekly"): + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail="schedule must be 'daily' or 'weekly'", + ) + + # Validate and normalise the URL before storing + try: + validated_url = validate_url(data.url) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=f"Invalid URL: {e}", + ) + + # Prevent duplicate schedules for the same URL per user + existing = await db.execute( + select(ScheduledScan).where( + ScheduledScan.user_id == current_user.id, + ScheduledScan.url == validated_url, + ) + ) + if existing.scalar_one_or_none(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail="A scheduled scan for this URL already exists. " + "Delete the existing one or use the toggle endpoint to resume it.", + ) + + scan = ScheduledScan( + user_id=current_user.id, + url=validated_url, + schedule=data.schedule, + ) + db.add(scan) + await db.flush() + await db.refresh(scan) + return scan + + +@router.get("", response_model=list[ScheduledScanResponse]) +async def list_scheduled_scans( + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Return all scheduled scans belonging to the authenticated user.""" + result = await db.execute( + select(ScheduledScan) + .where(ScheduledScan.user_id == current_user.id) + .order_by(ScheduledScan.created_at.desc()) + ) + return result.scalars().all() + + +@router.patch("/{scan_id}/toggle", response_model=ScheduledScanResponse) +async def toggle_scheduled_scan( + scan_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """ + Flip the is_active flag on a scheduled scan. + + Active → paused: the scan is skipped by the scheduler until resumed. + Paused → active: the scan is eligible for the next scheduler run. + When re-activating, last_run_at is cleared so the scan runs immediately + on the next scheduler tick rather than waiting for the full window. + """ + result = await db.execute( + select(ScheduledScan).where( + ScheduledScan.id == scan_id, + ScheduledScan.user_id == current_user.id, + ) + ) + scan = result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled scan not found") + + scan.is_active = not scan.is_active + + # Clear last_run_at when re-activating so the next scheduler tick picks it up immediately + if scan.is_active: + scan.last_run_at = None + + await db.flush() + await db.refresh(scan) + return scan + + +@router.delete("/{scan_id}", status_code=status.HTTP_204_NO_CONTENT) +async def delete_scheduled_scan( + scan_id: str, + current_user: User = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + """Permanently delete a scheduled scan.""" + result = await db.execute( + select(ScheduledScan).where( + ScheduledScan.id == scan_id, + ScheduledScan.user_id == current_user.id, + ) + ) + scan = result.scalar_one_or_none() + + if not scan: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Scheduled scan not found") + + await db.delete(scan)