Files
manage/backend/src/media_library_viewer_api/models/widgets.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

112 lines
3.2 KiB
Python

"""Pydantic models for the dashboard widget system.
Widgets are either:
* **service-bound** — reference a ``service_id`` and a ``widget_kind`` declared
by that service's definition (Grafana link, Prometheus metric, Jellyfin
activity, SSH task output); or
* **built-in** — ``service_id`` is null and ``widget_kind`` is one of the
service-less kinds (backups, static).
"""
from __future__ import annotations
from typing import Any
from pydantic import BaseModel, Field, field_validator, model_validator
FORBIDDEN_CONFIG_KEYS = {
"password",
"token",
"secret",
"api_key",
"apikey",
"private_key",
"passphrase",
"credential",
}
def _looks_secret(value: Any) -> bool:
"""Heuristic to detect values that look like secrets/tokens."""
if not isinstance(value, str) or not value.strip():
return False
lowered = value.lower()
if value.startswith("sk-") or value.startswith("eyJ"):
return True
if len(value) > 64 and lowered.isalnum():
return True
return False
def _validate_config_keys(config: dict[str, Any]) -> dict[str, Any]:
"""Recursively reject credential keys and secret-looking values."""
for key, value in config.items():
if key.lower() in FORBIDDEN_CONFIG_KEYS:
raise ValueError(f"Credential key '{key}' is not allowed in widget config")
if _looks_secret(value):
raise ValueError(f"Value for '{key}' looks like a secret")
if isinstance(value, dict):
_validate_config_keys(value)
elif isinstance(value, list):
for item in value:
if isinstance(item, dict):
_validate_config_keys(item)
return config
class _WidgetInstanceBase(BaseModel):
"""Shared fields between input and output widget models."""
service_id: str | None = None
widget_kind: str = Field(..., min_length=1)
title: str = Field(..., min_length=1)
config: dict[str, Any] = Field(default_factory=dict)
enabled: bool = True
sort_order: int = Field(default=0, ge=0)
@field_validator("config")
@classmethod
def reject_credential_keys(cls, value: dict[str, Any]) -> dict[str, Any]:
return _validate_config_keys(value or {})
@model_validator(mode="after")
def _validate_kind(self) -> "_WidgetInstanceBase":
# The kind must be non-empty (Field enforces it); service_id may be None
# for built-ins. Deeper validation happens in the router against the
# service definition / built-in registry.
return self
class WidgetInstanceInput(_WidgetInstanceBase):
"""Payload for creating or updating a widget instance."""
id: str | None = None
class WidgetInstance(_WidgetInstanceBase):
"""Persisted widget instance returned by the API."""
id: str
created_at: int
updated_at: int
class BuiltinWidgetKindInfo(BaseModel):
"""Metadata about a built-in (service-less) widget kind."""
kind: str
name: str
description: str
config_schema: dict[str, Any]
default_config: dict[str, Any]
refresh_interval_ms: int
class WidgetDataResponse(BaseModel):
"""Response from the per-widget data endpoint."""
widget_id: str
data: dict[str, Any] | None = None
error: str | None = None
fetched_at: int