Files
manage/backend/src/media_library_viewer_api/routers/widgets.py
T

216 lines
7.8 KiB
Python

"""REST API for dashboard widget instances.
Widgets are either service-bound (``service_id`` + ``widget_kind`` from the
service definition) or built-in (``service_id`` is null; ``widget_kind`` is one
of the service-less kinds exposed by ``GET /api/widgets/builtin``).
"""
from __future__ import annotations
import logging
import time
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.integrations.base import validate_config
from media_library_viewer_api.integrations.registry import get_service_definition
from media_library_viewer_api.models.widgets import (
BuiltinWidgetKindInfo,
WidgetDataResponse,
WidgetInstance,
WidgetInstanceInput,
)
from media_library_viewer_api.services.settings_store import SettingsStore
from media_library_viewer_api.widgets.builtin import (
BUILTIN_WIDGET_KINDS,
is_builtin_kind,
validate_builtin_config,
)
from media_library_viewer_api.widgets.sources import (
build_service_record,
get_builtin_adapter,
get_service_adapter,
)
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
logger = logging.getLogger(__name__)
def _validate_widget_input(body: WidgetInstanceInput, store: SettingsStore) -> None:
"""Validate widget_kind + config against the service definition or built-ins."""
if body.service_id:
service = store.get_service(body.service_id)
if not service:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Service {body.service_id} not found",
)
definition = get_service_definition(service["service_type"])
if definition is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Unknown service type: {service['service_type']}",
)
widget_kind = definition.widget_kind(body.widget_kind)
if widget_kind is None:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(f"Service type '{service['service_type']}' does not provide widget kind '{body.widget_kind}'"),
)
if widget_kind.config_model is not None:
try:
validate_config(widget_kind.config_model, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
else:
if not is_builtin_kind(body.widget_kind):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=(
f"Unknown built-in widget kind '{body.widget_kind}' (set service_id for service-bound widgets)"
),
)
try:
validate_builtin_config(body.widget_kind, body.config)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"Invalid widget config: {exc}",
) from exc
@router.get("/builtin")
def list_builtin_kinds() -> list[BuiltinWidgetKindInfo]:
"""Return metadata for service-less built-in widget kinds."""
return [
BuiltinWidgetKindInfo(
kind=wk.kind,
name=wk.name,
description=wk.description,
config_schema=wk.config_schema,
default_config=wk.default_config,
refresh_interval_ms=wk.refresh_interval_ms,
)
for wk in BUILTIN_WIDGET_KINDS.values()
]
@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, store)
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, store)
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"}
@router.get("/instances/{widget_id}/data")
async def fetch_data(
widget_id: str,
store: SettingsStore = Depends(get_settings_store),
) -> dict[str, Any]:
"""Fetch widget data through the registered source adapter."""
widget = store.get_widget(widget_id)
if not widget:
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
service_id = widget.get("service_id")
widget_kind = widget.get("widget_kind") or ""
service: Any = None
if service_id:
service_row = store.get_service(service_id)
if not service_row:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Service {service_id} not found",
fetched_at=int(time.time()),
).model_dump()
if not service_row.get("enabled", True):
return WidgetDataResponse(
widget_id=widget_id,
error="Service is disabled",
fetched_at=int(time.time()),
).model_dump()
adapter = get_service_adapter(service_row["service_type"])
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"No adapter for service type {service_row['service_type']}",
fetched_at=int(time.time()),
).model_dump()
service = build_service_record(store, service_row)
else:
adapter = get_builtin_adapter(widget_kind)
if adapter is None:
return WidgetDataResponse(
widget_id=widget_id,
error=f"Unknown built-in widget kind: {widget_kind}",
fetched_at=int(time.time()),
).model_dump()
try:
data = await adapter.fetch(service, widget_kind, widget.get("config") or {})
except Exception as exc: # pragma: no cover - defensive
logger.exception("Unhandled adapter exception widget_id=%s", widget_id)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail="Widget data fetch failed",
) from exc
return WidgetDataResponse(
widget_id=widget_id,
data=data if "error" not in data else None,
error=data.get("error"),
fetched_at=int(time.time()),
).model_dump()