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).
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -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"],
|
||||
|
||||
@@ -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"}
|
||||
@@ -1 +0,0 @@
|
||||
from .users_impl import * # noqa: F401,F403
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
|
||||
|
||||
+1
-152
@@ -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": "<p>Hi there</p>",
|
||||
"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 ---
|
||||
|
||||
|
||||
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user