Files
manage/backend/src/media_library_viewer_api/integrations/base.py
T
Developer 10fd4ead4a 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.
2026-06-22 16:42:56 +00:00

120 lines
3.5 KiB
Python

"""Base classes for service integrations.
A *service definition* is a closed, compile-time description of an external service
the app can talk to (Grafana, Jellyfin, …). Each definition declares:
* its non-secret ``config_schema`` (derived from a Pydantic model),
* the secret fields it accepts (API keys / tokens),
* the widget kinds it can contribute to the dashboard (each with its own
Pydantic-derived config schema).
Definitions live in :mod:`media_library_viewer_api.integrations` modules and are
assembled into the closed :data:`~media_library_viewer_api.integrations.registry.SERVICE_DEFINITIONS`
map. There is no runtime plugin loading.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any
from pydantic import BaseModel
class ServiceConfigBase(BaseModel):
"""Base for per-service non-secret config models.
Subclass this in each integration module and declare the connection fields.
The JSON schema is derived via ``model_json_schema()`` and exposed to the UI.
"""
class WidgetConfigBase(BaseModel):
"""Base for per-widget config models.
Subclass this for each widget kind a service provides. Widget configs never
hold secrets; credentials live on the parent service record.
"""
model_config = {"extra": "forbid"}
@dataclass(frozen=True)
class SecretField:
"""A secret field stored encrypted on the service record."""
key: str
label: str
required: bool = False
helper: str | None = None
@dataclass(frozen=True)
class WidgetKind:
"""A widget kind contributed by a service definition."""
kind: str
name: str
description: str
config_schema: dict[str, Any]
default_config: dict[str, Any] = field(default_factory=dict)
refresh_interval_ms: int = 0
config_model: type[WidgetConfigBase] | None = None
@dataclass(frozen=True)
class ServiceDefinition:
"""Closed description of an external service type."""
service_type: str
name: str
description: str
config_model: type[ServiceConfigBase]
secret_fields: list[SecretField]
widget_kinds: list[WidgetKind]
@property
def config_schema(self) -> dict[str, Any]:
"""JSON schema for the service's non-secret config."""
return self.config_model.model_json_schema()
@property
def secret_keys(self) -> set[str]:
return {sf.key for sf in self.secret_fields}
def widget_kind(self, kind: str) -> WidgetKind | None:
for wk in self.widget_kinds:
if wk.kind == kind:
return wk
return None
def widget_kind(
kind: str,
name: str,
description: str,
model_cls: type[WidgetConfigBase],
*,
default_config: dict[str, Any] | None = None,
refresh_interval_ms: int = 0,
) -> WidgetKind:
"""Build a :class:`WidgetKind` from a Pydantic widget-config model."""
schema = model_cls.model_json_schema()
# Strip Pydantic's title noise so the exposed schema stays clean.
schema.pop("title", None)
return WidgetKind(
kind=kind,
name=name,
description=description,
config_schema=schema,
default_config=dict(default_config or {}),
refresh_interval_ms=refresh_interval_ms,
config_model=model_cls,
)
def validate_config(model_cls: type[BaseModel], config: dict[str, Any] | None) -> dict[str, Any]:
"""Validate a config dict against a Pydantic model and return the cleaned dict."""
instance = model_cls.model_validate(config or {})
return instance.model_dump(exclude_none=True)