from fastapi import APIRouter, Depends, HTTPException, Request from fastapi.responses import PlainTextResponse, RedirectResponse from fastapi.templating import Jinja2Templates from sqlalchemy import select from app.db import get_db from app.models import JobRun, ScheduledJob from app.security.deps import require_auth from app.services import scheduler_service router = APIRouter(prefix="/settings/jobs", tags=["jobs"]) templates = Jinja2Templates(directory="app/templates") def _format_next_run(dt) -> str: """Same HH:MM (24h, zero-padded) convention the playlists' simplified time field uses, with a short day qualifier when it's not today -- maintenance jobs can be weekly/monthly, so the date can't just be dropped the way it is for playlists' plain daily time.""" if dt is None: return "-" now = dt.__class__.now(dt.tzinfo) if dt.date() == now.date(): return dt.strftime("%H:%M") return dt.strftime("%a %H:%M") def _job_list_context(db): scheduler = scheduler_service.get_scheduler() registered = {j.id: j for j in (scheduler.get_jobs() if scheduler else [])} enabled_by_key = { row.job_key: row.enabled for row in db.execute(select(ScheduledJob)).scalars() } maintenance_jobs = [] for job_key in scheduler_service.MAINTENANCE_JOBS: job = registered.get(job_key) next_run = getattr(job, "next_run_time", None) if job else None maintenance_jobs.append( { "job_key": job_key, "label": scheduler_service.humanize_job_key(job_key), "description": scheduler_service.MAINTENANCE_JOB_DESCRIPTIONS.get(job_key, ""), "next_run": _format_next_run(next_run), "enabled": enabled_by_key.get(job_key, True), } ) recent_runs = list( db.execute(select(JobRun).order_by(JobRun.started_at.desc()).limit(50)).scalars() ) return { "maintenance_jobs": maintenance_jobs, "recent_runs": recent_runs, "humanize_job_key": scheduler_service.humanize_job_key, } @router.get("") async def jobs_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): return templates.TemplateResponse(request, "jobs/index.html", _job_list_context(db)) @router.get("/_runs_table") async def runs_table_partial(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)): """HTMX polling target: just the recent-runs table body, so the jobs page can auto-refresh run status without a full page reload.""" return templates.TemplateResponse(request, "jobs/_runs_table.html", _job_list_context(db)) @router.post("/{job_key:path}/run") async def run_now(job_key: str, user: dict = Depends(require_auth)): scheduler = scheduler_service.get_scheduler() if scheduler is None: raise HTTPException(503, "scheduler not running") try: await scheduler_service.trigger_now(scheduler, job_key) except ValueError as exc: raise HTTPException(404, str(exc)) return RedirectResponse(url="/settings/jobs", status_code=303) @router.post("/{job_key:path}/toggle") async def toggle_enabled(job_key: str, user: dict = Depends(require_auth), db=Depends(get_db)): scheduler = scheduler_service.get_scheduler() if scheduler is None: raise HTTPException(503, "scheduler not running") row = db.execute(select(ScheduledJob).where(ScheduledJob.job_key == job_key)).scalar_one_or_none() currently_enabled = row.enabled if row else True scheduler_service.set_maintenance_enabled(scheduler, job_key, not currently_enabled) return RedirectResponse(url="/settings/jobs", status_code=303) @router.get("/runs/{run_id}/log", response_class=PlainTextResponse) async def view_log(run_id: int, user: dict = Depends(require_auth), db=Depends(get_db)): run = db.get(JobRun, run_id) if run is None or not run.log_path: raise HTTPException(404, "no such run/log") from pathlib import Path path = Path(run.log_path) if not path.exists(): raise HTTPException(404, "log file no longer exists") return path.read_text(errors="replace")