216 lines
7.8 KiB
Python
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()
|