Add playlists, credentials, and jobs pages; wire up dashboard
Vendored htmx.min.js (1.9.12) and alpine.min.js (3.14.1) into static/ -- self-hosted per the no-CDN/CSP requirement, included in base.html. routers/playlists.py: list/add/remove playlists, a detail page showing live per-playlist status via status_service (Spotify vs. beets vs. sldl index vs. quarantine), edit form for active/cron_expr/no_m3u/notes, and a "Run now" button wired to the jobs run-now endpoint. routers/credentials.py: write-only credential forms per scope (a saved secret is never sent back to the browser -- fields just show "(set)" and leaving a field blank keeps its current value, matching credential_service.set_credentials' partial-update semantics). routers/jobs.py: maintenance job list with run-now/enable-disable, and a recent-runs table that auto-refreshes via HTMX polling (hx-trigger="every 5s") against a partial-only endpoint -- the one place in the UI where avoiding a full-page reload actually matters, since job status changes while the page is sitting open. Per-run log viewing. Dashboard now shows real data (beets stats, playlist counts, recent job runs) instead of the placeholder from Task 2. Verified all 10 authenticated pages render successfully (200, no template errors) via TestClient with require_auth overridden, including the playlist detail page's graceful-degradation path when Spotify credentials aren't configured yet (renders a "couldn't fetch live status" message instead of a 500).
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.db import get_db
|
||||
from app.security.deps import require_auth
|
||||
from app.services import credential_service
|
||||
|
||||
router = APIRouter(prefix="/settings/credentials", tags=["credentials"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def credentials_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||
# Write-only by design: never send a previously-saved secret value back
|
||||
# to the browser. Each scope just reports which fields are already set.
|
||||
configured = {
|
||||
scope: set(credential_service.get_scope(db, scope).keys())
|
||||
for scope in credential_service.SCOPE_FIELDS
|
||||
}
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"credentials/index.html",
|
||||
{"scope_fields": credential_service.SCOPE_FIELDS, "configured": configured},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{scope}")
|
||||
async def save_credentials(
|
||||
scope: str, request: Request, user: dict = Depends(require_auth), db=Depends(get_db)
|
||||
):
|
||||
if scope not in credential_service.SCOPE_FIELDS:
|
||||
raise HTTPException(404, "unknown credential scope")
|
||||
form = await request.form()
|
||||
values = {
|
||||
field: value
|
||||
for field, value in form.items()
|
||||
if field in credential_service.SCOPE_FIELDS[scope] and value != ""
|
||||
}
|
||||
credential_service.set_credentials(db, scope, values)
|
||||
return RedirectResponse(url="/settings/credentials", status_code=303)
|
||||
@@ -1,16 +1,31 @@
|
||||
from fastapi import APIRouter, Depends, Request
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
|
||||
from app.db import get_db
|
||||
from app.models import JobRun
|
||||
from app.security.deps import require_auth
|
||||
from app.services import beets_service, playlist_service
|
||||
|
||||
router = APIRouter(tags=["dashboard"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("/")
|
||||
async def dashboard(request: Request, user: dict = Depends(require_auth)):
|
||||
async def dashboard(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||
stats = beets_service.stats()
|
||||
playlists = playlist_service.list_all(db)
|
||||
recent_runs = list(
|
||||
db.execute(select(JobRun).order_by(JobRun.started_at.desc()).limit(10)).scalars()
|
||||
)
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"dashboard.html",
|
||||
{"user": user},
|
||||
{
|
||||
"user": user,
|
||||
"stats": stats,
|
||||
"playlist_count": len(playlists),
|
||||
"active_playlist_count": len([p for p in playlists if p.active]),
|
||||
"recent_runs": recent_runs,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
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="/jobs", tags=["jobs"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
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)
|
||||
maintenance_jobs.append(
|
||||
{
|
||||
"job_key": job_key,
|
||||
"next_run": getattr(job, "next_run_time", None) if job else None,
|
||||
"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}
|
||||
|
||||
|
||||
@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="/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="/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")
|
||||
@@ -0,0 +1,80 @@
|
||||
from fastapi import APIRouter, Depends, Form, HTTPException, Request
|
||||
from fastapi.responses import RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
|
||||
from app.db import get_db
|
||||
from app.security.deps import require_auth
|
||||
from app.services import playlist_service, status_service
|
||||
|
||||
router = APIRouter(prefix="/playlists", tags=["playlists"])
|
||||
templates = Jinja2Templates(directory="app/templates")
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def playlists_index(request: Request, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||
playlists = playlist_service.list_all(db)
|
||||
return templates.TemplateResponse(request, "playlists/index.html", {"playlists": playlists})
|
||||
|
||||
|
||||
@router.post("")
|
||||
async def create_playlist(
|
||||
name: str = Form(...),
|
||||
spotify_url: str = Form(...),
|
||||
cron_expr: str = Form(""),
|
||||
no_m3u: bool = Form(False),
|
||||
user: dict = Depends(require_auth),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
playlist_service.create(
|
||||
db, name=name, spotify_url=spotify_url, cron_expr=cron_expr or None, no_m3u=no_m3u
|
||||
)
|
||||
return RedirectResponse(url="/playlists", status_code=303)
|
||||
|
||||
|
||||
@router.get("/{playlist_id}")
|
||||
async def playlist_detail(
|
||||
request: Request, playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)
|
||||
):
|
||||
playlist = playlist_service.get(db, playlist_id)
|
||||
if playlist is None:
|
||||
raise HTTPException(404, "no such playlist")
|
||||
|
||||
status = None
|
||||
error = None
|
||||
try:
|
||||
status = await status_service.playlist_status(db, playlist.name, playlist.spotify_url)
|
||||
except Exception as exc:
|
||||
error = str(exc)
|
||||
|
||||
return templates.TemplateResponse(
|
||||
request,
|
||||
"playlists/detail.html",
|
||||
{"playlist": playlist, "status": status, "error": error},
|
||||
)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}")
|
||||
async def update_playlist(
|
||||
playlist_id: int,
|
||||
active: bool = Form(False),
|
||||
cron_expr: str = Form(""),
|
||||
no_m3u: bool = Form(False),
|
||||
notes: str = Form(""),
|
||||
user: dict = Depends(require_auth),
|
||||
db=Depends(get_db),
|
||||
):
|
||||
playlist_service.update(
|
||||
db,
|
||||
playlist_id,
|
||||
active=active,
|
||||
cron_expr=cron_expr or None,
|
||||
no_m3u=no_m3u,
|
||||
notes=notes or None,
|
||||
)
|
||||
return RedirectResponse(url=f"/playlists/{playlist_id}", status_code=303)
|
||||
|
||||
|
||||
@router.post("/{playlist_id}/delete")
|
||||
async def delete_playlist(playlist_id: int, user: dict = Depends(require_auth), db=Depends(get_db)):
|
||||
playlist_service.delete(db, playlist_id)
|
||||
return RedirectResponse(url="/playlists", status_code=303)
|
||||
Reference in New Issue
Block a user