""" Dashboard-Layout v1: Validierung, Produkt-Standard (Übersicht) und Lab-Standard. Erlaubte Widget-IDs und Reihenfolge: widget_catalog.WIDGET_CATALOG. """ from __future__ import annotations import copy from typing import Any, Literal from pydantic import BaseModel, Field, field_validator, model_validator from dashboard_widget_config import validate_widget_entry_config from widget_catalog import ( ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS, WIDGET_CATALOG, ) # Abwärtskompatibel (Tests importieren weiterhin aus diesem Modul) __all__ = [ "ALLOWED_WIDGET_IDS", "DashboardLayoutPayload", "DashboardWidgetEntry", "coalesce_effective_layout", "default_layout_dict", "lab_default_layout_dict", "merge_missing_catalog_widgets", "product_default_layout_dict", ] def lab_default_layout_dict() -> dict[str, Any]: """Standard für Dashboard-Lab (Experimentier-Widgets).""" on = DEFAULT_LAB_WIDGET_IDS return { "version": 1, "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG], } def product_default_layout_dict() -> dict[str, Any]: """Code-Fallback für die Produkt-Übersicht; live-Standard ggf. system_config (siehe get_product_default_base_dict).""" on = DEFAULT_PRODUCT_DASHBOARD_WIDGET_IDS return { "version": 1, "widgets": [{"id": e["id"], "enabled": e["id"] in on} for e in WIDGET_CATALOG], } def default_layout_dict() -> dict[str, Any]: """Alias: Produkt-Standard (coalesce, Reset). Lab nutzt lab_default_layout_dict().""" return product_default_layout_dict() def merge_missing_catalog_widgets(layout: dict[str, Any]) -> dict[str, Any]: """ Hängt fehlende Widget-IDs aus WIDGET_CATALOG an (enabled=False, leere config). Bestehende Reihenfolge bleibt erhalten — nötig, damit neue Katalog-Einträge in „Übersicht anpassen“ / Lab erscheinen, ohne dass Nutzer:innen das Layout resetten müssen. """ out = copy.deepcopy(layout) widgets: list[dict[str, Any]] = list(out.get("widgets") or []) seen: set[str] = {str(w["id"]) for w in widgets if w.get("id")} for e in WIDGET_CATALOG: wid = e["id"] if wid not in seen: widgets.append({"id": wid, "enabled": False, "config": {}}) seen.add(wid) out["version"] = out.get("version", 1) out["widgets"] = widgets return out class DashboardWidgetEntry(BaseModel): id: str = Field(min_length=1, max_length=64) enabled: bool = True config: dict[str, Any] = Field(default_factory=dict) @field_validator("config", mode="before") @classmethod def _config_coerce(cls, v: Any) -> dict[str, Any]: if v is None: return {} if not isinstance(v, dict): raise ValueError("config muss Objekt sein") return v @model_validator(mode="after") def _normalize_widget_config(self) -> DashboardWidgetEntry: normalized = validate_widget_entry_config(self.id, self.config) return self.model_copy(update={"config": normalized}) class DashboardLayoutPayload(BaseModel): version: Literal[1] = 1 widgets: list[DashboardWidgetEntry] = Field(min_length=1, max_length=32) @model_validator(mode="after") def _validate_widgets(self) -> DashboardLayoutPayload: ids = [w.id for w in self.widgets] if len(ids) != len(set(ids)): raise ValueError("Doppelte widget id") bad = [i for i in ids if i not in ALLOWED_WIDGET_IDS] if bad: raise ValueError(f"Unbekannte Widget-IDs: {bad}") if not any(w.enabled for w in self.widgets): raise ValueError("Mindestens ein Widget muss aktiv sein") return self def to_stored_dict(self) -> dict[str, Any]: out_widgets: list[dict[str, Any]] = [] for w in self.widgets: d: dict[str, Any] = {"id": w.id, "enabled": w.enabled} if w.config: d["config"] = dict(w.config) out_widgets.append(d) return {"version": self.version, "widgets": out_widgets} def coalesce_effective_layout(raw: Any) -> tuple[bool, dict[str, Any]]: """ Returns (has_custom, effective_layout). has_custom=True nur wenn DB-Wert vorhanden und gültig (v1). """ if raw is None: return False, default_layout_dict() parsed_obj: Any = raw if isinstance(raw, str): import json try: parsed_obj = json.loads(raw) except json.JSONDecodeError: return False, default_layout_dict() if not isinstance(parsed_obj, dict): return False, default_layout_dict() try: parsed = DashboardLayoutPayload.model_validate( { "version": parsed_obj.get("version", 1), "widgets": parsed_obj.get("widgets", []), } ) return True, parsed.to_stored_dict() except Exception: return False, default_layout_dict()