feat(widgets): add backend CRUD, registry, and default seeding

Introduce a closed, compile-time widget registry and backend CRUD for
dashboard widget instances.

- Add dashboard_widgets SQLite table in SettingsStore with CRUD helpers and
  default seeding (Jellyfin + Backups) on first install.
- Add Pydantic models with credential-key and secret-value rejection.
- Add widgets router: /api/widgets/sources, /types, /instances CRUD.
- Call ensure_defaults() in app lifespan so fresh installs seed defaults.
- Add backend tests covering registry, CRUD, validation, and seeding.
- Include SDD artifacts: exploration, proposal, spec, design, tasks.
This commit is contained in:
Developer
2026-06-19 20:07:47 +00:00
parent 24427b4869
commit 200d319fb0
13 changed files with 2894 additions and 6 deletions
@@ -0,0 +1,113 @@
"""REST API for dashboard widget instances and registry metadata."""
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, status
from media_library_viewer_api.dependencies import get_settings_store
from media_library_viewer_api.models.widgets import WidgetInstance, WidgetInstanceInput, WidgetTypeInfo
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.registry import (
list_source_types,
list_widget_types,
validate_config,
)
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
def _registry_for_type(widget_type: str) -> dict[str, Any]:
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
info = WIDGET_REGISTRY.get(widget_type)
if not info:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown widget type: {widget_type}",
)
return info
def _validate_widget_input(body: WidgetInstanceInput) -> None:
"""Validate widget_type/addon_id match and config schema."""
info = _registry_for_type(body.widget_type)
expected_addon = info["addon_id"]
if body.addon_id != expected_addon:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Widget type '{body.widget_type}' belongs to addon "
f"'{expected_addon}', not '{body.addon_id}'"
),
)
try:
validate_config(body.widget_type, body.config)
except ValueError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
@router.get("/sources")
def list_sources() -> list[str]:
"""Return all registered widget source types."""
return list_source_types()
@router.get("/types")
def list_types() -> list[WidgetTypeInfo]:
"""Return metadata for all registered widget types."""
return [info.model_dump() for info in list_widget_types()]
@router.get("/instances")
def list_instances(
store: SettingsStore = Depends(get_settings_store),
) -> list[dict[str, Any]]:
"""Return all persisted widget instances."""
return [WidgetInstance(**widget).model_dump() for widget in store.list_widgets()]
@router.post("/instances", status_code=status.HTTP_201_CREATED)
def create_instance(
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Create a new widget instance."""
_validate_widget_input(body)
widget = store.upsert_widget(body.model_dump())
return WidgetInstance(**widget).model_dump()
@router.put("/instances/{widget_id}")
def update_instance(
widget_id: str,
body: WidgetInstanceInput,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Update an existing widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
if body.id is not None and body.id != widget_id:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail="ID in path does not match ID in body",
)
_validate_widget_input(body)
widget = store.upsert_widget(body.model_dump(), widget_id)
return WidgetInstance(**widget).model_dump()
@router.delete("/instances/{widget_id}")
def delete_instance(
widget_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, str]:
"""Delete a widget instance."""
existing = store.get_widget(widget_id)
if not existing:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
store.delete_widget(widget_id)
return {"status": "deleted"}