feat(widgets): rebind widgets to the service registry
PR 2 of 4 for the runtime service registry change. - dashboard_widgets gains service_id + widget_kind columns (legacy addon_id/widget_type kept but unused). - Source adapters take (service: ServiceRecord | None, widget_kind, config). SERVICE_ADAPTERS keyed by service_type; BUILTIN_ADAPTERS for backups/static. - Backups and static stay as service-less built-ins (service_id nullable), exposed via GET /api/widgets/builtin. - SSH task adapter resolves the task + instance, runs over SSH, and appends a service_task_runs history row on success/failure/timeout/error. - Retire widgets/registry.py; widget metadata now comes from the integrations registry + widgets/builtin. Remove /api/widgets/types and /api/widgets/sources. - Stop default widget seeding (fresh install = empty dashboard). - Rewrite widget tests around the service-bound + built-in model (26 tests). Backend-only breaking change; frontend is reconciled in Slice 3. Build/lint stay green; pytest 222 passed.
This commit is contained in:
@@ -1,4 +1,11 @@
|
||||
"""REST API for dashboard widget instances and registry metadata."""
|
||||
"""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
|
||||
@@ -7,68 +14,95 @@ 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.registry import (
|
||||
get_widget_info,
|
||||
list_source_types,
|
||||
list_widget_types,
|
||||
validate_config,
|
||||
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,
|
||||
)
|
||||
from media_library_viewer_api.widgets.sources import get_source_adapter
|
||||
|
||||
router = APIRouter(prefix="/api/widgets", tags=["widgets"])
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _registry_for_type(widget_type: str) -> dict[str, Any]:
|
||||
from media_library_viewer_api.widgets.registry import WIDGET_REGISTRY
|
||||
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 "
|
||||
f"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
|
||||
|
||||
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}",
|
||||
|
||||
@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,
|
||||
)
|
||||
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[dict[str, Any]]:
|
||||
"""Return metadata for all registered widget types."""
|
||||
return [info.model_dump() for info in list_widget_types()]
|
||||
for wk in BUILTIN_WIDGET_KINDS.values()
|
||||
]
|
||||
|
||||
|
||||
@router.get("/instances")
|
||||
@@ -85,7 +119,7 @@ def create_instance(
|
||||
store: SettingsStore = Depends(get_settings_store),
|
||||
) -> dict[str, Any]:
|
||||
"""Create a new widget instance."""
|
||||
_validate_widget_input(body)
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump())
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
@@ -105,7 +139,7 @@ def update_instance(
|
||||
status_code=status.HTTP_400_BAD_REQUEST,
|
||||
detail="ID in path does not match ID in body",
|
||||
)
|
||||
_validate_widget_input(body)
|
||||
_validate_widget_input(body, store)
|
||||
widget = store.upsert_widget(body.model_dump(), widget_id)
|
||||
return WidgetInstance(**widget).model_dump()
|
||||
|
||||
@@ -133,31 +167,44 @@ async def fetch_data(
|
||||
if not widget:
|
||||
raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Widget not found")
|
||||
|
||||
widget_type = widget["widget_type"]
|
||||
info = get_widget_info(widget_type)
|
||||
if info is None:
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=None,
|
||||
error=f"Unknown widget type: {widget_type}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
service_id = widget.get("service_id")
|
||||
widget_kind = widget.get("widget_kind") or ""
|
||||
|
||||
adapter = get_source_adapter(info.source_type)
|
||||
if adapter is None:
|
||||
# Defensive: registry should prevent this, but return a safe error.
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=None,
|
||||
error=f"No adapter registered for source type: {info.source_type}",
|
||||
fetched_at=int(time.time()),
|
||||
).model_dump()
|
||||
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(widget["config"])
|
||||
except Exception as exc:
|
||||
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,
|
||||
@@ -166,7 +213,6 @@ async def fetch_data(
|
||||
|
||||
return WidgetDataResponse(
|
||||
widget_id=widget_id,
|
||||
widget_type=widget_type,
|
||||
data=data if "error" not in data else None,
|
||||
error=data.get("error"),
|
||||
fetched_at=int(time.time()),
|
||||
|
||||
Reference in New Issue
Block a user