From a43d6a6206526c0080b10804499647fd1520cfdb Mon Sep 17 00:00:00 2001 From: Developer Date: Fri, 26 Jun 2026 18:33:24 +0000 Subject: [PATCH] Backend: drop users router, backups service attribution, named dashboards (Slice 3) Users router removed: - Delete routers/users.py + users_impl.py (Jellyfin-backed user directory, Jellyfin-email message compose, Jellyseerr enrichment). - Drop orphaned get_jellyseerr_client dep from dependencies.py (get_user_id stays; used by dashboard/media/media_index_worker). - clients/jellyseerr.py stays (still imported by widgets/sources.py). - test_api.py TestUsers block + mock_jellyseerr fixture removed. Backups service attribution: - backup_jobs gains a nullable service_id column (PRAGMA migration). - _resolve_backup_service_id helper: explicit service_id wins, else first-wins an enabled backups instance, else empty (backward-compat). - Both report endpoints accept ?service_id= and persist it on the job. - Dashboard summary + poller aggregate across all jobs unchanged. Named dashboards backend: - named_dashboards table (id, label, slug UNIQUE, sort_order, payload_json, timestamps) with full CRUD methods + _slugify/_unique_slug helpers. - models/dashboards.py (NamedDashboardInput/NamedDashboard). - routers/dashboards.py: GET/POST/PUT/DELETE /api/dashboards. - Router registered in main.py. Tests: test_dashboards.py (CRUD, slug collision, explicit slug, 404); test_api.py trimmed. 271 backend tests pass (was 268; +6 dashboards -3 users); ruff clean. Refs openspec/changes/services-as-hub-ia/ (spec R5/R6.1, tasks slice 3). --- .../media_library_viewer_api/dependencies.py | 17 - backend/src/media_library_viewer_api/main.py | 5 +- .../models/dashboards.py | 29 ++ .../routers/backups.py | 28 +- .../routers/dashboards.py | 45 ++ .../media_library_viewer_api/routers/users.py | 1 - .../routers/users_impl.py | 389 ------------------ .../services/settings_store.py | 141 ++++++- backend/tests/test_api.py | 153 +------ backend/tests/test_dashboards.py | 97 +++++ 10 files changed, 337 insertions(+), 568 deletions(-) create mode 100644 backend/src/media_library_viewer_api/models/dashboards.py create mode 100644 backend/src/media_library_viewer_api/routers/dashboards.py delete mode 100644 backend/src/media_library_viewer_api/routers/users.py delete mode 100644 backend/src/media_library_viewer_api/routers/users_impl.py create mode 100644 backend/tests/test_dashboards.py diff --git a/backend/src/media_library_viewer_api/dependencies.py b/backend/src/media_library_viewer_api/dependencies.py index ef00d8b..8eb4f52 100644 --- a/backend/src/media_library_viewer_api/dependencies.py +++ b/backend/src/media_library_viewer_api/dependencies.py @@ -18,7 +18,6 @@ from typing import Any from fastapi import HTTPException, Request from media_library_viewer_api.clients.jellyfin import JellyfinClient -from media_library_viewer_api.clients.jellyseerr import JellyseerrClient from media_library_viewer_api.clients.local import LocalCommandClient from media_library_viewer_api.clients.ssh import RemoteSSHClient from media_library_viewer_api.config import get_settings @@ -178,22 +177,6 @@ def get_jellyfin_client(request: Request = None) -> JellyfinClient: return _jellyfin_client_for(cache_key) -def get_jellyseerr_client(request: Request = None) -> JellyseerrClient | None: - """Return a cached Jellyseerr client when configured, otherwise None.""" - store = get_settings_store() - service_id = _request_jellyfin_service_id(request) - service = _service_record(store, "jellyseerr", service_id) - if service is None: - logger.info("Jellyseerr client not configured (no jellyseerr service)") - return None - base_url = str(service.get("config", {}).get("base_url") or "") - api_key = str(service.get("secrets", {}).get("api_key") or "") - if not base_url or not api_key: - logger.info("Jellyseerr service is missing base_url or api_key") - return None - return JellyseerrClient(base_url, api_key) - - def _ssh_client_from_machine_config(machine: dict[str, Any], store: SettingsStore | None = None) -> RemoteSSHClient: """Build a RemoteSSHClient from a machine config dict.""" store = store or get_settings_store() diff --git a/backend/src/media_library_viewer_api/main.py b/backend/src/media_library_viewer_api/main.py index 1604988..6ec2373 100644 --- a/backend/src/media_library_viewer_api/main.py +++ b/backend/src/media_library_viewer_api/main.py @@ -25,7 +25,8 @@ from media_library_viewer_api.routers import ( authentik_users as authentik_users_router, ) from media_library_viewer_api.routers import backups as backups_router -from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks, users +from media_library_viewer_api.routers import dashboard, files, jobs, media, monitoring, tasks +from media_library_viewer_api.routers import dashboards as dashboards_router from media_library_viewer_api.routers import services as services_router from media_library_viewer_api.routers import widgets as widgets_router from media_library_viewer_api.routers.settings import router as settings_router @@ -139,11 +140,11 @@ app.include_router(monitoring.router) app.include_router(media.router) app.include_router(files.router) app.include_router(jobs.router) -app.include_router(users.router) app.include_router(tasks.router) app.include_router(settings_router) app.include_router(backups_router.router) app.include_router(widgets_router.router) +app.include_router(dashboards_router.router) app.include_router(services_router.router) app.include_router(authentik_users_router.router) diff --git a/backend/src/media_library_viewer_api/models/dashboards.py b/backend/src/media_library_viewer_api/models/dashboards.py new file mode 100644 index 0000000..cb72a3b --- /dev/null +++ b/backend/src/media_library_viewer_api/models/dashboards.py @@ -0,0 +1,29 @@ +"""Pydantic models for the named-dashboards API.""" + +from __future__ import annotations + +from typing import Any + +from pydantic import BaseModel, Field + + +class NamedDashboardInput(BaseModel): + """Input for create/update of a named dashboard.""" + + id: str | None = None + label: str = Field(default="Dashboard") + slug: str | None = None + sort_order: int = 0 + payload: dict[str, Any] = Field(default_factory=dict) + + +class NamedDashboard(BaseModel): + """A named dashboard record.""" + + id: str + label: str + slug: str + sort_order: int + payload: dict[str, Any] + created_at: int + updated_at: int diff --git a/backend/src/media_library_viewer_api/routers/backups.py b/backend/src/media_library_viewer_api/routers/backups.py index 085fb6a..eec4dac 100644 --- a/backend/src/media_library_viewer_api/routers/backups.py +++ b/backend/src/media_library_viewer_api/routers/backups.py @@ -15,7 +15,23 @@ from ..services.settings_store import SettingsStore, get_settings_store router = APIRouter(prefix="/api/backups", tags=["backups"]) -def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dict[str, Any]: +def _resolve_backup_service_id(store: SettingsStore, explicit: str | None = None) -> str: + """Return the service_id for backup attribution. + + First-wins: if no explicit service_id is given, pick the first enabled + ``backups`` service instance (spec R6.1). Returns an empty string when + none is configured (backward-compatible with pre-service reports). + """ + if explicit: + return explicit + candidates = store.list_services("backups") + for svc in candidates: + if svc.get("enabled"): + return svc["id"] + return "" + + +def _get_or_create_job(store: SettingsStore, report: BackupReportRequest, service_id: str = "") -> dict[str, Any]: job = store.get_backup_job_by_name(report.name) if not job: job = store.upsert_backup_job( @@ -24,6 +40,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, + "service_id": service_id, } ) elif report.schedule_interval_seconds: @@ -34,6 +51,7 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic "source": report.source, "target": report.target, "schedule_interval_seconds": report.schedule_interval_seconds, + "service_id": service_id, } ) job = store.get_backup_job(job["id"]) @@ -43,10 +61,12 @@ def _get_or_create_job(store: SettingsStore, report: BackupReportRequest) -> dic @router.post("/report") def post_backup_report( report: BackupReportRequest, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: - job = _get_or_create_job(store, report) + resolved_service_id = _resolve_backup_service_id(store, service_id) + job = _get_or_create_job(store, report, resolved_service_id) # Check for duplicate (same job + started_at within 1s) existing_runs = store.list_backup_runs(job_id=job["id"], limit=5) @@ -88,10 +108,12 @@ def post_backup_report( @router.post("/report/start") def post_backup_start( report: BackupReportRequest, + service_id: str | None = None, store: SettingsStore = Depends(get_settings_store), _auth: str = Depends(require_api_key), ) -> BackupRunResponse: - job = _get_or_create_job(store, report) + resolved_service_id = _resolve_backup_service_id(store, service_id) + job = _get_or_create_job(store, report, resolved_service_id) run_data = { "job_id": job["id"], diff --git a/backend/src/media_library_viewer_api/routers/dashboards.py b/backend/src/media_library_viewer_api/routers/dashboards.py new file mode 100644 index 0000000..34b2930 --- /dev/null +++ b/backend/src/media_library_viewer_api/routers/dashboards.py @@ -0,0 +1,45 @@ +"""Named dashboards CRUD router.""" + +from __future__ import annotations + +from fastapi import APIRouter, Depends, HTTPException + +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.models.dashboards import NamedDashboard, NamedDashboardInput +from media_library_viewer_api.services.settings_store import SettingsStore + +router = APIRouter(prefix="/api/dashboards", tags=["dashboards"]) + + +@router.get("") +def list_dashboards(store: SettingsStore = Depends(get_settings_store)) -> list[NamedDashboard]: + rows = store.list_dashboards() + return [NamedDashboard(**row) for row in rows] + + +@router.post("") +def create_dashboard(body: NamedDashboardInput, store: SettingsStore = Depends(get_settings_store)) -> NamedDashboard: + row = store.upsert_dashboard(body.model_dump()) + return NamedDashboard(**row) + + +@router.put("/{dashboard_id}") +def update_dashboard( + dashboard_id: str, + body: NamedDashboardInput, + store: SettingsStore = Depends(get_settings_store), +) -> NamedDashboard: + if not store.get_dashboard(dashboard_id): + raise HTTPException(status_code=404, detail="Dashboard not found") + if body.id and body.id != dashboard_id: + raise HTTPException(status_code=400, detail="ID mismatch") + row = store.upsert_dashboard(body.model_dump(), dashboard_id) + return NamedDashboard(**row) + + +@router.delete("/{dashboard_id}") +def delete_dashboard(dashboard_id: str, store: SettingsStore = Depends(get_settings_store)) -> dict[str, str]: + if not store.get_dashboard(dashboard_id): + raise HTTPException(status_code=404, detail="Dashboard not found") + store.delete_dashboard(dashboard_id) + return {"status": "deleted"} diff --git a/backend/src/media_library_viewer_api/routers/users.py b/backend/src/media_library_viewer_api/routers/users.py deleted file mode 100644 index 3e6d7bf..0000000 --- a/backend/src/media_library_viewer_api/routers/users.py +++ /dev/null @@ -1 +0,0 @@ -from .users_impl import * # noqa: F401,F403 diff --git a/backend/src/media_library_viewer_api/routers/users_impl.py b/backend/src/media_library_viewer_api/routers/users_impl.py deleted file mode 100644 index 275e130..0000000 --- a/backend/src/media_library_viewer_api/routers/users_impl.py +++ /dev/null @@ -1,389 +0,0 @@ -"""Users router — Jellyfin list plus optional Jellyseerr enrichment.""" - -from __future__ import annotations - -import json -import logging -from typing import Any - -from fastapi import APIRouter, Depends, File, Form, HTTPException, UploadFile, status - -from media_library_viewer_api.clients.jellyfin import JellyfinClient -from media_library_viewer_api.clients.jellyseerr import JellyseerrClient -from media_library_viewer_api.config import get_settings -from media_library_viewer_api.dependencies import ( - get_jellyfin_client, - get_jellyseerr_client, - get_mail_queue, -) -from media_library_viewer_api.services.mailer import EmailAttachment, validate_smtp_settings - -logger = logging.getLogger(__name__) - -router = APIRouter(prefix="/api/users", tags=["users"]) - - -_PERMISSION_FLAGS = [ - (2, "admin"), - (4, "manage_settings"), - (8, "manage_users"), - (16, "manage_requests"), - (32, "request"), - (64, "vote"), - (128, "auto_approve"), - (256, "auto_approve_movie"), - (512, "auto_approve_tv"), - (1024, "request_4k"), - (2048, "request_4k_movie"), - (4096, "request_4k_tv"), - (8192, "request_advanced"), - (16384, "request_view"), - (32768, "auto_approve_4k"), - (65536, "auto_approve_4k_movie"), - (131072, "auto_approve_4k_tv"), - (262144, "request_movie"), - (524288, "request_tv"), - (1048576, "manage_issues"), - (2097152, "view_issues"), -] - -_USER_TYPES = { - 1: "plex", - 2: "local", - 3: "jellyfin", - 4: "emby", -} - - -def _safe_int(value: Any) -> int: - try: - return int(value) - except (TypeError, ValueError): - return 0 - - -def _permission_labels(permissions: int) -> list[str]: - labels = [label for bit, label in _PERMISSION_FLAGS if permissions & bit] - return labels or ["none"] - - -def _role_label(permissions: int) -> str: - if permissions & 2: - return "admin" - if permissions & (4 | 8 | 16): - return "manager" - if permissions & (32 | 64 | 128): - return "requester" - return "user" - - -def _account_type(user_type: Any) -> str: - return _USER_TYPES.get(_safe_int(user_type), "unknown") - - -def _merge_users( - jellyfin_users: list[dict[str, Any]], - jellyseerr_jellyfin_users: list[dict[str, Any]] | None, - jellyseerr_users: list[dict[str, Any]] | None, - jellyseerr_client: JellyseerrClient | None, -) -> dict[str, Any]: - def _normalize(value: Any) -> str: - return str(value or "").strip().lower() - - def _looks_like_email(value: Any) -> bool: - text = str(value or "").strip() - return bool(text and "@" in text and " " not in text) - - def _pick_source_and_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]: - for source, value in candidates: - if _looks_like_email(value): - return source, str(value).strip() - return "", "" - - def _first_value(candidates: list[tuple[str, Any]]) -> tuple[str, str]: - for source, value in candidates: - text = str(value or "").strip() - if text: - return source, text - return "", "" - - def _source_summary(name_source: str, email_source: str, avatar_source: str, access_source: str) -> str: - return ", ".join( - [ - f"name={name_source or 'none'}", - f"email={email_source or 'none'}", - f"avatar={avatar_source or 'none'}", - f"access={access_source or 'none'}", - ] - ) - - def _lookup_keys(item: dict[str, Any]) -> list[str]: - return [ - _normalize(item.get("id")), - _normalize(item.get("Id")), - _normalize(item.get("userId")), - _normalize(item.get("user_id")), - _normalize(item.get("jellyfinUserId")), - _normalize(item.get("jellyfin_user_id")), - _normalize(item.get("jellyfinUsername")), - _normalize(item.get("jellyfin_username")), - _normalize(item.get("username")), - _normalize(item.get("displayName")), - _normalize(item.get("display_name")), - ] - - linked_by_jellyfin_id: dict[str, dict[str, Any]] = {} - for item in jellyseerr_jellyfin_users or []: - for key in ( - item.get("id"), - item.get("Id"), - item.get("userId"), - item.get("user_id"), - item.get("jellyfinUserId"), - item.get("jellyfin_user_id"), - ): - normalized = _normalize(key) - if normalized: - linked_by_jellyfin_id[normalized] = item - - seerr_by_key: dict[str, dict[str, Any]] = {} - for item in jellyseerr_users or []: - for key in _lookup_keys(item): - if key: - seerr_by_key[key] = item - - items: list[dict[str, Any]] = [] - enriched_count = 0 - for user in jellyfin_users: - jellyfin_id = str(user.get("Id") or user.get("id") or "") - jellyfin_name = str(user.get("Name") or user.get("name") or "") - jf_link = linked_by_jellyfin_id.get(_normalize(jellyfin_id)) - - seerr_user = None - for candidate in [ - jellyfin_name, - (jf_link or {}).get("jellyfinUsername"), - (jf_link or {}).get("jellyfin_username"), - (jf_link or {}).get("username"), - (jf_link or {}).get("displayName"), - (jf_link or {}).get("display_name"), - ]: - seerr_user = seerr_by_key.get(_normalize(candidate)) - if seerr_user: - break - - email_source, email = _pick_source_and_value( - [ - ("jellyseerr:user", (seerr_user or {}).get("email")), - ("jellyseerr:jellyfin", (jf_link or {}).get("email")), - ] - ) - avatar_source, avatar = _first_value( - [ - ("jellyseerr:user", (seerr_user or {}).get("avatar")), - ("jellyseerr:jellyfin", (jf_link or {}).get("thumb")), - ("jellyseerr:jellyfin", (jf_link or {}).get("avatar")), - ] - ) - if avatar and jellyseerr_client: - avatar = jellyseerr_client.absolute_url(avatar) - - permissions = _safe_int((seerr_user or {}).get("permissions")) - user_type = _safe_int((seerr_user or {}).get("userType") or (seerr_user or {}).get("user_type")) - role = _role_label(permissions) - access_source = "jellyseerr:user" if seerr_user else "" - name_source = "jellyfin" - summary = _source_summary(name_source, email_source, avatar_source, access_source) - - if seerr_user or jf_link: - enriched_count += 1 - - items.append( - { - "jellyfin_id": jellyfin_id, - "username": jellyfin_name, - "display_name": jellyfin_name, - "email": email, - "email_source": email_source, - "avatar": avatar, - "avatar_source": avatar_source, - "contactable": bool(email), - "source": summary, - "source_summary": summary, - "name_source": name_source, - "access_source": access_source, - "jellyseerr_user_id": _safe_int((seerr_user or {}).get("id") or (seerr_user or {}).get("userId")) - or None, - "jellyseerr_username": str( - (seerr_user or {}).get("username") or (seerr_user or {}).get("jellyfinUsername") or "" - ), - "user_type": user_type or None, - "user_type_label": _account_type(user_type), - "role": role, - "permissions": permissions, - "permissions_label": ", ".join(_permission_labels(permissions)), - "request_count": _safe_int((seerr_user or {}).get("requestCount")) or None, - } - ) - - logger.info( - "Users merged jellyfin=%s jellyseerr_jellyfin=%s jellyseerr_users=%s enriched=%s", - len(jellyfin_users), - len(jellyseerr_jellyfin_users or []), - len(jellyseerr_users or []), - enriched_count, - ) - return { - "items": items, - "total": len(items), - "jellyseerr_configured": jellyseerr_client is not None, - "jellyseerr_available": bool(jellyseerr_users or jellyseerr_jellyfin_users), - "jellyseerr_jellyfin_user_count": len(jellyseerr_jellyfin_users or []), - "jellyseerr_user_count": len(jellyseerr_users or []), - "enriched_count": enriched_count, - } - - -@router.get("") -def get_users( - jellyfin: JellyfinClient = Depends(get_jellyfin_client), - jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client), -) -> dict[str, Any]: - """Return the known users, enriched with Jellyseerr data when available.""" - jellyfin_users = jellyfin.users() - logger.info("Users endpoint fetched %s Jellyfin users", len(jellyfin_users)) - jellyseerr_jellyfin_users: list[dict[str, Any]] | None = None - jellyseerr_users: list[dict[str, Any]] | None = None - jellyseerr_error = "" - if jellyseerr: - try: - jellyseerr_jellyfin_users = jellyseerr.jellyfin_users() - except Exception as exc: # pragma: no cover - network fallback - logger.exception("Jellyseerr Jellyfin-linked user fetch failed") - jellyseerr_error = f"Jellyseerr Jellyfin users fetch failed: {exc}" - try: - jellyseerr_users = jellyseerr.users() - except Exception as exc: # pragma: no cover - network fallback - logger.exception("Jellyseerr user list fetch failed") - jellyseerr_error = ( - f"{jellyseerr_error}; " if jellyseerr_error else "" - ) + f"Jellyseerr user list fetch failed: {exc}" - - result = _merge_users(jellyfin_users, jellyseerr_jellyfin_users, jellyseerr_users, jellyseerr) - result["jellyseerr_error"] = jellyseerr_error - logger.info( - "Users response total=%s configured=%s available=%s enriched=%s error=%s", - result["total"], - result["jellyseerr_configured"], - result["jellyseerr_available"], - result["enriched_count"], - bool(jellyseerr_error), - ) - return result - - -@router.get("/message/status") -def get_user_message_status(mail_queue=Depends(get_mail_queue)) -> dict[str, Any]: - """Return the current background email queue status.""" - return mail_queue.status() - - -@router.post("/message", status_code=status.HTTP_202_ACCEPTED) -async def post_user_message( - recipient_ids: str = Form(...), - subject: str = Form(...), - html_body: str = Form(""), - text_body: str = Form(""), - attachments: list[UploadFile] | None = File(default=None), - jellyfin: JellyfinClient = Depends(get_jellyfin_client), - jellyseerr: JellyseerrClient | None = Depends(get_jellyseerr_client), - mail_queue=Depends(get_mail_queue), -) -> dict[str, Any]: - """Queue a single email to the selected users without blocking the API.""" - try: - requested_ids = json.loads(recipient_ids) - except json.JSONDecodeError as exc: - raise HTTPException(status_code=400, detail=f"recipient_ids must be valid JSON: {exc}") from exc - - if not isinstance(requested_ids, list): - raise HTTPException(status_code=400, detail="recipient_ids must be a JSON list") - - cleaned_ids = [str(item).strip() for item in requested_ids if str(item).strip()] - if not cleaned_ids: - raise HTTPException(status_code=400, detail="At least one recipient is required") - - subject = subject.strip() - if not subject: - raise HTTPException(status_code=400, detail="Subject is required") - - directory = get_users(jellyfin=jellyfin, jellyseerr=jellyseerr) - users_by_id = {str(item.get("jellyfin_id") or ""): item for item in directory.get("items", [])} - - recipients: list[str] = [] - recipient_labels: list[str] = [] - skipped: list[dict[str, str]] = [] - for user_id in cleaned_ids: - item = users_by_id.get(user_id) - if not item: - skipped.append({"jellyfin_id": user_id, "reason": "not found"}) - continue - email = str(item.get("email") or "").strip() - if not email: - skipped.append({"jellyfin_id": user_id, "reason": "missing email"}) - continue - recipients.append(email) - recipient_labels.append(f"{item.get('display_name') or item.get('username') or user_id} <{email}>") - - if not recipients: - raise HTTPException(status_code=400, detail="No selected users have a deliverable email address") - - settings = get_settings() - validate_smtp_settings(settings) - - queue_status = mail_queue.status() - if not queue_status["worker_running"]: - raise HTTPException(status_code=503, detail="Email queue worker is not running") - - attachment_payloads: list[EmailAttachment] = [] - for upload in attachments or []: - data = await upload.read() - if not data: - continue - attachment_payloads.append( - EmailAttachment( - filename=upload.filename or "attachment", - content_type=upload.content_type or "application/octet-stream", - data=data, - ) - ) - - request_id = mail_queue.enqueue( - settings=settings, - recipients=recipients, - subject=subject, - html_body=html_body, - text_body=text_body, - attachments=attachment_payloads, - ) - from_address = ( - str(getattr(settings, "smtp_from_address", "") or "").strip() - or str(getattr(settings, "smtp_username", "") or "").strip() - ) - logger.info( - "Users message queued request_id=%s subject=%s recipients=%s attachments=%s skipped=%s", - request_id, - subject, - len(recipients), - len(attachment_payloads), - len(skipped), - ) - return { - "status": "queued", - "request_id": request_id, - "from_address": from_address, - "recipient_count": len(recipients), - "attachment_count": len(attachment_payloads), - "subject": subject, - "recipient_labels": recipient_labels, - "skipped": skipped, - } diff --git a/backend/src/media_library_viewer_api/services/settings_store.py b/backend/src/media_library_viewer_api/services/settings_store.py index 9ebde32..30e0ec0 100644 --- a/backend/src/media_library_viewer_api/services/settings_store.py +++ b/backend/src/media_library_viewer_api/services/settings_store.py @@ -175,6 +175,9 @@ class SettingsStore: created_at INTEGER NOT NULL ) """) + backup_job_cols = {col[1] for col in conn.execute("PRAGMA table_info(backup_jobs)").fetchall()} + if "service_id" not in backup_job_cols: + conn.execute("ALTER TABLE backup_jobs ADD COLUMN service_id TEXT") conn.execute(""" CREATE TABLE IF NOT EXISTS backup_runs ( id TEXT PRIMARY KEY, @@ -247,6 +250,19 @@ class SettingsStore: conn.execute( "CREATE INDEX IF NOT EXISTS idx_service_task_runs_task ON service_task_runs(task_id, created_at DESC)" ) + conn.execute( + """ + CREATE TABLE IF NOT EXISTS named_dashboards ( + id TEXT PRIMARY KEY, + label TEXT NOT NULL, + slug TEXT NOT NULL UNIQUE, + sort_order INTEGER NOT NULL DEFAULT 0, + payload_json TEXT NOT NULL DEFAULT '{}', + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL + ) + """ + ) @staticmethod def _normalize_services(value: Any, fallback: list[str] | None = None) -> list[str]: @@ -964,6 +980,7 @@ class SettingsStore: "source": row["source"], "target": row["target"], "schedule_interval_seconds": row["schedule_interval_seconds"], + "service_id": row["service_id"], "created_at": row["created_at"], } @@ -982,12 +999,18 @@ class SettingsStore: schedule_interval_seconds = (current or {}).get("schedule_interval_seconds") if schedule_interval_seconds is not None: schedule_interval_seconds = int(schedule_interval_seconds) + service_id = str( + payload.get("service_id") + if payload.get("service_id") is not None + else (current or {}).get("service_id", "") or "" + ).strip() return { "id": job_id, "name": name, "source": source, "target": target, "schedule_interval_seconds": schedule_interval_seconds, + "service_id": service_id, } def get_backup_job_by_name(self, name: str) -> dict[str, Any] | None: @@ -1005,17 +1028,19 @@ class SettingsStore: created_at = int(existing[0]) if existing else now conn.execute( """ - INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, created_at) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO backup_jobs (id, name, source, target, schedule_interval_seconds, service_id, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT(id) DO UPDATE SET name = excluded.name, source = excluded.source, target = excluded.target, - schedule_interval_seconds = excluded.schedule_interval_seconds + schedule_interval_seconds = excluded.schedule_interval_seconds, + service_id = excluded.service_id ON CONFLICT(name) DO UPDATE SET source = excluded.source, target = excluded.target, - schedule_interval_seconds = excluded.schedule_interval_seconds + schedule_interval_seconds = excluded.schedule_interval_seconds, + service_id = excluded.service_id """, ( job["id"], @@ -1023,6 +1048,7 @@ class SettingsStore: job["source"], job["target"], job["schedule_interval_seconds"], + job["service_id"], created_at, ), ) @@ -1638,6 +1664,113 @@ class SettingsStore: for row in rows ] + # ------------------------------------------------------------------ + # Named dashboards + # ------------------------------------------------------------------ + + @staticmethod + def _slugify(label: str) -> str: + import re + + slug = re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") + return slug or "dashboard" + + def _unique_slug(self, slug: str, exclude_id: str | None = None) -> str: + self.init_schema() + base = slug + suffix = 1 + with self.connect() as conn: + while True: + row = conn.execute( + "SELECT id FROM named_dashboards WHERE slug = ? AND id != ?", + (slug, exclude_id or ""), + ).fetchone() + if not row: + return slug + suffix += 1 + slug = f"{base}-{suffix}" + + def _row_to_dashboard(self, row: sqlite3.Row) -> dict[str, Any]: + return { + "id": row["id"], + "label": row["label"], + "slug": row["slug"], + "sort_order": row["sort_order"], + "payload": json.loads(row["payload_json"] or "{}"), + "created_at": row["created_at"], + "updated_at": row["updated_at"], + } + + def list_dashboards(self) -> list[dict[str, Any]]: + self.init_schema() + with self.connect() as conn: + rows = conn.execute( + "SELECT * FROM named_dashboards ORDER BY sort_order ASC, label COLLATE NOCASE" + ).fetchall() + return [self._row_to_dashboard(row) for row in rows] + + def get_dashboard(self, dashboard_id: str | None) -> dict[str, Any] | None: + if not dashboard_id: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM named_dashboards WHERE id = ?", (dashboard_id,)).fetchone() + return self._row_to_dashboard(row) if row else None + + def get_dashboard_by_slug(self, slug: str | None) -> dict[str, Any] | None: + if not slug: + return None + self.init_schema() + with self.connect() as conn: + row = conn.execute("SELECT * FROM named_dashboards WHERE slug = ?", (slug,)).fetchone() + return self._row_to_dashboard(row) if row else None + + def upsert_dashboard(self, payload: dict[str, Any], dashboard_id: str | None = None) -> dict[str, Any]: + self.init_schema() + current = self.get_dashboard(dashboard_id) if dashboard_id else None + dash_id = str(payload.get("id") or dashboard_id or uuid.uuid4().hex[:12]).strip() + label = str(payload.get("label") or (current or {}).get("label") or "Dashboard").strip() + slug = str(payload.get("slug") or "").strip() or self._slugify(label) + slug = self._unique_slug(slug, exclude_id=dash_id) + sort_order = payload.get("sort_order") + if sort_order is None: + sort_order = (current or {}).get("sort_order", 0) + sort_order = int(sort_order) + payload_data = payload.get("payload") + if payload_data is None: + payload_data = (current or {}).get("payload", {}) + now = int(time.time()) + with self.connect() as conn: + existing = conn.execute("SELECT created_at FROM named_dashboards WHERE id = ?", (dash_id,)).fetchone() + created_at = int(existing[0]) if existing else now + conn.execute( + """ + INSERT INTO named_dashboards (id, label, slug, sort_order, payload_json, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT(id) DO UPDATE SET + label = excluded.label, + slug = excluded.slug, + sort_order = excluded.sort_order, + payload_json = excluded.payload_json, + updated_at = excluded.updated_at + """, + ( + dash_id, + label, + slug, + sort_order, + json.dumps(payload_data), + created_at, + now, + ), + ) + return self.get_dashboard(dash_id) or {"id": dash_id, "label": label, "slug": slug} + + def delete_dashboard(self, dashboard_id: str) -> None: + self.init_schema() + with self.connect() as conn: + conn.execute("DELETE FROM named_dashboards WHERE id = ?", (dashboard_id,)) + _store: SettingsStore | None = None diff --git a/backend/tests/test_api.py b/backend/tests/test_api.py index c986952..6bedb04 100644 --- a/backend/tests/test_api.py +++ b/backend/tests/test_api.py @@ -14,8 +14,6 @@ from fastapi.testclient import TestClient from media_library_viewer_api.clients.ssh import CommandResult from media_library_viewer_api.dependencies import ( get_jellyfin_client, - get_jellyseerr_client, - get_mail_queue, get_settings_store, get_ssh_client, get_user_id, @@ -70,38 +68,6 @@ def mock_jellyfin(): return client -@pytest.fixture -def mock_jellyseerr(): - """Mock Jellyseerr client.""" - client = MagicMock() - client.jellyfin_users.return_value = [ - {"id": "jf1", "username": "alex", "thumb": "/avatarproxy/alex", "email": "alex@example.com"}, - {"id": "jf2", "username": "sam", "thumb": "/avatarproxy/sam", "email": "sam@example.com"}, - ] - client.users.return_value = [ - { - "id": 7, - "username": "alex", - "email": "alex@example.com", - "avatar": "/avatarproxy/alex", - "userType": 3, - "permissions": 10, - "requestCount": 3, - }, - { - "id": 8, - "username": "sam", - "email": "sam@example.com", - "avatar": "/avatarproxy/sam", - "userType": 2, - "permissions": 32, - "requestCount": 1, - }, - ] - client.absolute_url.side_effect = lambda path: f"https://requests.example.com{path}" - return client - - @pytest.fixture def mock_ssh(): """Mock SSH client.""" @@ -132,10 +98,9 @@ def mock_ssh(): @pytest.fixture -def test_client(mock_jellyfin, mock_jellyseerr, mock_ssh, tmp_path): +def test_client(mock_jellyfin, mock_ssh, tmp_path): """FastAPI test client with mocked dependencies.""" app.dependency_overrides[get_jellyfin_client] = lambda: mock_jellyfin - app.dependency_overrides[get_jellyseerr_client] = lambda: mock_jellyseerr app.dependency_overrides[get_ssh_client] = lambda: mock_ssh app.dependency_overrides[get_user_id] = lambda: "user123" store = SettingsStore(tmp_path / "settings.sqlite") @@ -293,122 +258,6 @@ class TestSettingsReset: assert len(store.list_machines()) == 0 -# --- Users --- - - -class TestUsers: - def test_users_list_enriched(self, test_client): - response = test_client.get("/api/users") - assert response.status_code == 200 - data = response.json() - assert data["total"] == 2 - assert data["jellyseerr_configured"] is True - assert data["jellyseerr_available"] is True - assert data["jellyseerr_error"] == "" - - alex = next(item for item in data["items"] if item["username"] == "alex") - assert alex["email"] == "alex@example.com" - assert alex["email_source"] == "jellyseerr:user" - assert alex["contactable"] is True - assert alex["avatar"].startswith("https://requests.example.com/") - assert alex["avatar_source"] == "jellyseerr:user" - assert alex["permissions"] == 10 - assert alex["permissions_label"] == "admin, manage_users" - assert alex["role"] == "admin" - assert alex["user_type_label"] == "jellyfin" - assert alex["request_count"] == 3 - assert "name=jellyfin" in alex["source_summary"] - assert "email=jellyseerr:user" in alex["source_summary"] - - sam = next(item for item in data["items"] if item["username"] == "sam") - assert sam["role"] == "requester" - assert sam["user_type_label"] == "local" - assert sam["email"] == "sam@example.com" - - def test_users_message_status(self, test_client): - mail_queue = MagicMock() - mail_queue.status.return_value = { - "state": "idle", - "worker_running": True, - "stop_requested": False, - "pending_count": 0, - "active_request_id": None, - "last_request_id": None, - "last_result": None, - "last_error": "", - "last_error_at": None, - "last_success_at": None, - "last_activity_at": None, - "sent_count": 0, - "failed_count": 0, - } - app.dependency_overrides[get_mail_queue] = lambda: mail_queue - try: - response = test_client.get("/api/users/message/status") - finally: - app.dependency_overrides.pop(get_mail_queue, None) - assert response.status_code == 200 - assert response.json()["state"] == "idle" - assert response.json()["pending_count"] == 0 - - def test_users_message_is_queued(self, test_client): - mail_queue = MagicMock() - mail_queue.status.return_value = { - "state": "idle", - "worker_running": True, - "stop_requested": False, - "pending_count": 0, - "active_request_id": None, - "last_request_id": None, - "last_result": None, - "last_error": "", - "last_error_at": None, - "last_success_at": None, - "last_activity_at": None, - "sent_count": 0, - "failed_count": 0, - } - mail_queue.enqueue.return_value = "mail-123456" - app.dependency_overrides[get_mail_queue] = lambda: mail_queue - settings = SimpleNamespace( - smtp_host="smtp.example.com", - smtp_port=587, - smtp_username="mailer@example.com", - smtp_password="secret", - smtp_from_address="mailer@example.com", - smtp_from_name="Manage", - smtp_use_tls=True, - smtp_use_ssl=False, - smtp_timeout=15, - ) - - try: - with patch("media_library_viewer_api.routers.users_impl.get_settings", return_value=settings): - response = test_client.post( - "/api/users/message", - data={ - "recipient_ids": json.dumps(["jf1", "jf2"]), - "subject": "Hello team", - "html_body": "

Hi there

", - "text_body": "Hi there", - }, - ) - finally: - app.dependency_overrides.pop(get_mail_queue, None) - - assert response.status_code == 202 - data = response.json() - assert data["status"] == "queued" - assert data["request_id"] == "mail-123456" - assert data["recipient_count"] == 2 - assert data["attachment_count"] == 0 - mail_queue.enqueue.assert_called_once() - kwargs = mail_queue.enqueue.call_args.kwargs - assert kwargs["recipients"] == ["alex@example.com", "sam@example.com"] - assert kwargs["subject"] == "Hello team" - assert kwargs["settings"] is settings - - # --- Files --- diff --git a/backend/tests/test_dashboards.py b/backend/tests/test_dashboards.py new file mode 100644 index 0000000..8d30e7e --- /dev/null +++ b/backend/tests/test_dashboards.py @@ -0,0 +1,97 @@ +"""Tests for named-dashboards CRUD + slug uniqueness.""" + +from __future__ import annotations + +from pathlib import Path + +from fastapi.testclient import TestClient + +from media_library_viewer_api.dependencies import get_settings_store +from media_library_viewer_api.main import app +from media_library_viewer_api.services.settings_store import SettingsStore + + +def _client(tmp_path: Path) -> TestClient: + store = SettingsStore(tmp_path / "settings.sqlite") + app.dependency_overrides[get_settings_store] = lambda: store + client = TestClient(app) + client.store = store # type: ignore[attr-defined] + return client + + +def test_create_and_list_dashboards(tmp_path: Path): + client = _client(tmp_path) + try: + resp = client.post( + "/api/dashboards", + json={"label": "Storage Overview", "payload": {"widgets": []}}, + ) + assert resp.status_code == 200 + created = resp.json() + assert created["label"] == "Storage Overview" + assert created["slug"] == "storage-overview" + assert created["payload"] == {"widgets": []} + + listed = client.get("/api/dashboards").json() + assert len(listed) == 1 + assert listed[0]["id"] == created["id"] + finally: + app.dependency_overrides.clear() + + +def test_update_dashboard(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post("/api/dashboards", json={"label": "First"}).json() + updated = client.put( + f"/api/dashboards/{created['id']}", + json={"label": "Renamed", "payload": {"widgets": ["w1"]}}, + ).json() + assert updated["label"] == "Renamed" + assert updated["payload"] == {"widgets": ["w1"]} + assert updated["slug"] == "renamed" + finally: + app.dependency_overrides.clear() + + +def test_delete_dashboard(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post("/api/dashboards", json={"label": "Temp"}).json() + resp = client.delete(f"/api/dashboards/{created['id']}") + assert resp.status_code == 200 + assert client.get("/api/dashboards").json() == [] + finally: + app.dependency_overrides.clear() + + +def test_slug_collision_appends_suffix(tmp_path: Path): + client = _client(tmp_path) + try: + first = client.post("/api/dashboards", json={"label": "Overview"}).json() + second = client.post("/api/dashboards", json={"label": "Overview"}).json() + assert first["slug"] == "overview" + assert second["slug"] == "overview-2" + finally: + app.dependency_overrides.clear() + + +def test_explicit_slug_respected(tmp_path: Path): + client = _client(tmp_path) + try: + created = client.post( + "/api/dashboards", + json={"label": "My Dashboard", "slug": "custom-slug"}, + ).json() + assert created["slug"] == "custom-slug" + finally: + app.dependency_overrides.clear() + + +def test_update_nonexistent_returns_404(tmp_path: Path): + client = _client(tmp_path) + try: + resp = client.put("/api/dashboards/nope", json={"label": "X"}) + assert resp.status_code == 404 + finally: + app.dependency_overrides.clear()