From 365ce49c6ac92fde1f1f69287c9199d8f490a60e Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 8 Apr 2026 10:32:18 +0200 Subject: [PATCH] feat: Introduce admin dashboard product standard management - Added new API endpoints for managing the product dashboard standard, including retrieval, update, and deletion functionalities. - Enhanced the DashboardConfigurePage to support admin mode for configuring the product dashboard standard. - Updated the admin navigation to include a link for the product dashboard standard configuration. - Refactored the dashboard layout logic to utilize the new product standard management features. - Bumped app_dashboard version to 1.10.0 to reflect these enhancements and changes. --- backend/dashboard_layout_schema.py | 2 +- backend/dashboard_widget_entitlements.py | 8 ++ backend/migrations/040_system_config.sql | 8 ++ backend/routers/admin.py | 58 ++++++++++ backend/routers/app_dashboard.py | 10 +- backend/system_dashboard_product_default.py | 77 +++++++++++++ .../test_system_dashboard_product_default.py | 45 ++++++++ backend/version.py | 9 +- frontend/src/App.jsx | 1 + frontend/src/config/adminNav.js | 5 + frontend/src/pages/DashboardConfigurePage.jsx | 101 +++++++++++++----- frontend/src/utils/api.js | 7 ++ 12 files changed, 299 insertions(+), 32 deletions(-) create mode 100644 backend/migrations/040_system_config.sql create mode 100644 backend/system_dashboard_product_default.py create mode 100644 backend/tests/test_system_dashboard_product_default.py diff --git a/backend/dashboard_layout_schema.py b/backend/dashboard_layout_schema.py index b7b75f5..8ed8713 100644 --- a/backend/dashboard_layout_schema.py +++ b/backend/dashboard_layout_schema.py @@ -39,7 +39,7 @@ def lab_default_layout_dict() -> dict[str, Any]: def product_default_layout_dict() -> dict[str, Any]: - """System-Standard für die Produkt-Übersicht (kein DB-Override).""" + """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, diff --git a/backend/dashboard_widget_entitlements.py b/backend/dashboard_widget_entitlements.py index 2cfc853..d1dd922 100644 --- a/backend/dashboard_widget_entitlements.py +++ b/backend/dashboard_widget_entitlements.py @@ -58,6 +58,14 @@ def widgets_catalog_payload(profile_id: str, conn) -> dict[str, Any]: } +def widgets_catalog_admin_payload() -> dict[str, Any]: + """Admin: alle Widgets als auswählbar (ohne Feature-Filter).""" + return { + "catalog_version": 1, + "widgets": [_public_row(e, allowed=True) for e in WIDGET_CATALOG], + } + + def apply_entitlements_to_layout_dict(layout: dict[str, Any], profile_id: str, conn) -> dict[str, Any]: """ Setzt enabled=False für Widgets ohne Berechtigung. Mindestens ein Widget bleibt aktiv (welcome). diff --git a/backend/migrations/040_system_config.sql b/backend/migrations/040_system_config.sql new file mode 100644 index 0000000..b452cfa --- /dev/null +++ b/backend/migrations/040_system_config.sql @@ -0,0 +1,8 @@ +-- Globale System-Konfiguration (Key/Value, JSONB) +CREATE TABLE IF NOT EXISTS system_config ( + key VARCHAR(64) PRIMARY KEY, + value JSONB NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_system_config_updated_at ON system_config (updated_at); diff --git a/backend/routers/admin.py b/backend/routers/admin.py index e7d5a62..871b2f2 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -7,12 +7,21 @@ import os import smtplib from email.mime.text import MIMEText from datetime import datetime +from typing import Any from fastapi import APIRouter, HTTPException, Depends from db import get_db, get_cursor, r2d from auth import require_admin, hash_pin from models import AdminProfileUpdate +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from dashboard_widget_entitlements import widgets_catalog_admin_payload +from system_dashboard_product_default import ( + delete_product_default_override, + get_product_default_base_dict, + get_stored_product_default_validated, + upsert_product_default_base, +) router = APIRouter(prefix="/api/admin", tags=["admin"]) @@ -155,3 +164,52 @@ def admin_test_email(data: dict, session: dict=Depends(require_admin)): return {"ok": True, "message": f"Test-E-Mail an {email} gesendet"} except Exception as e: raise HTTPException(500, f"Fehler beim Senden: {str(e)}") + + +@router.get("/widgets/catalog-full") +def admin_widgets_catalog_full(session: dict = Depends(require_admin)): + """Dashboard-Widget-Katalog ohne Feature-Filter (Konfiguration des Produkt-Standards).""" + _ = session + return widgets_catalog_admin_payload() + + +@router.get("/dashboard-product-default") +def admin_get_dashboard_product_default(session: dict = Depends(require_admin)): + """Aktueller Produkt-Dashboard-Standard (DB oder Code).""" + _ = session + with get_db() as conn: + layout = get_product_default_base_dict(conn) + from_database = get_stored_product_default_validated(conn) is not None + code_ref = product_default_layout_dict() + return { + "from_database": from_database, + "layout": layout, + "code_reference": code_ref, + } + + +@router.put("/dashboard-product-default") +def admin_put_dashboard_product_default( + body: dict[str, Any], + session: dict = Depends(require_admin), +): + """System-Standard persistieren (JSON wie Nutzer-Layout v1).""" + _ = session + try: + payload = DashboardLayoutPayload.model_validate(body) + except Exception as e: + raise HTTPException(422, str(e)) from e + stored = payload.to_stored_dict() + with get_db() as conn: + upsert_product_default_base(conn, stored) + return {"ok": True, "layout": stored, "from_database": True} + + +@router.delete("/dashboard-product-default") +def admin_delete_dashboard_product_default(session: dict = Depends(require_admin)): + """DB-Override entfernen; App fällt auf Code-Standard zurück.""" + _ = session + with get_db() as conn: + delete_product_default_override(conn) + layout = get_product_default_base_dict(conn) + return {"ok": True, "layout": layout, "from_database": False} diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index 19b489e..3f0aff6 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -13,11 +13,11 @@ from dashboard_layout_schema import ( DashboardLayoutPayload, coalesce_effective_layout, lab_default_layout_dict, - product_default_layout_dict, ) from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widgets_catalog_payload from db import get_cursor, get_db from routers.profiles import get_pid +from system_dashboard_product_default import get_product_default_base_dict router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"]) @@ -51,8 +51,11 @@ def get_dashboard_layout( raw = row["dashboard_layout"] if row else None custom, effective = coalesce_effective_layout(raw) with get_db() as conn: + base_product = get_product_default_base_dict(conn) + if not custom: + effective = base_product effective = apply_entitlements_to_layout_dict(effective, pid, conn) - product_adj = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn) + product_adj = apply_entitlements_to_layout_dict(base_product, pid, conn) lab_adj = apply_entitlements_to_layout_dict(lab_default_layout_dict(), pid, conn) return { "custom": custom, @@ -107,5 +110,6 @@ def reset_dashboard_layout( ) if cur.rowcount == 0: raise HTTPException(404, "Profil nicht gefunden") - cleared = apply_entitlements_to_layout_dict(product_default_layout_dict(), pid, conn) + base = get_product_default_base_dict(conn) + cleared = apply_entitlements_to_layout_dict(base, pid, conn) return {"ok": True, "layout": cleared} diff --git a/backend/system_dashboard_product_default.py b/backend/system_dashboard_product_default.py new file mode 100644 index 0000000..5c423d8 --- /dev/null +++ b/backend/system_dashboard_product_default.py @@ -0,0 +1,77 @@ +""" +Persistenter System-Standard für die Produkt-Übersicht (Dashboard). + +Key in system_config: dashboard_product_default — gültiges DashboardLayoutPayload (JSON). +""" +from __future__ import annotations + +from typing import Any + +from psycopg2.extras import Json + +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from db import get_cursor + +SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT = "dashboard_product_default" + + +def get_stored_product_default_validated(conn) -> dict[str, Any] | None: + """Gültiges Layout aus DB oder None (fehlt/ungültig).""" + cur = get_cursor(conn) + cur.execute( + "SELECT value FROM system_config WHERE key = %s", + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT,), + ) + row = cur.fetchone() + if not row or row.get("value") is None: + return None + raw = row["value"] + if isinstance(raw, str): + import json + + try: + raw = json.loads(raw) + except json.JSONDecodeError: + return None + if not isinstance(raw, dict): + return None + try: + payload = DashboardLayoutPayload.model_validate( + {"version": raw.get("version", 1), "widgets": raw.get("widgets", [])} + ) + return payload.to_stored_dict() + except Exception: + return None + + +def get_product_default_base_dict(conn) -> dict[str, Any]: + """Basis-Layout (ohne Entitlements): DB-Override oder Code-Standard.""" + stored = get_stored_product_default_validated(conn) + if stored is not None: + return stored + return product_default_layout_dict() + + +def upsert_product_default_base(conn, layout: dict[str, Any]) -> dict[str, Any]: + payload = DashboardLayoutPayload.model_validate(layout) + stored = payload.to_stored_dict() + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO system_config (key, value, updated_at) + VALUES (%s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP + """, + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT, Json(stored)), + ) + return stored + + +def delete_product_default_override(conn) -> None: + cur = get_cursor(conn) + cur.execute( + "DELETE FROM system_config WHERE key = %s", + (SYSTEM_CONFIG_KEY_DASHBOARD_PRODUCT_DEFAULT,), + ) diff --git a/backend/tests/test_system_dashboard_product_default.py b/backend/tests/test_system_dashboard_product_default.py new file mode 100644 index 0000000..8bd9af6 --- /dev/null +++ b/backend/tests/test_system_dashboard_product_default.py @@ -0,0 +1,45 @@ +from dashboard_layout_schema import DashboardLayoutPayload, product_default_layout_dict +from dashboard_widget_entitlements import widgets_catalog_admin_payload + + +def test_widgets_catalog_admin_all_allowed(): + p = widgets_catalog_admin_payload() + assert p["catalog_version"] == 1 + assert len(p["widgets"]) >= 1 + assert all(w["allowed"] is True for w in p["widgets"]) + + +def test_get_product_default_base_uses_code_when_no_row(monkeypatch): + from system_dashboard_product_default import get_product_default_base_dict + + class _Cur: + def execute(self, *a, **k): + pass + + def fetchone(self): + return None + + monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) + assert get_product_default_base_dict(object()) == product_default_layout_dict() + + +def test_get_product_default_base_uses_db_when_valid(monkeypatch): + from system_dashboard_product_default import get_product_default_base_dict + + from widget_catalog import ALLOWED_WIDGET_IDS + + small = { + "version": 1, + "widgets": [{"id": wid, "enabled": wid == "welcome"} for wid in sorted(ALLOWED_WIDGET_IDS)], + } + DashboardLayoutPayload.model_validate(small) + + class _Cur: + def execute(self, *a, **k): + pass + + def fetchone(self): + return {"value": small} + + monkeypatch.setattr("system_dashboard_product_default.get_cursor", lambda _c: _Cur()) + assert get_product_default_base_dict(object()) == small diff --git a/backend/version.py b/backend/version.py index 1e8c235..41d1c3c 100644 --- a/backend/version.py +++ b/backend/version.py @@ -9,7 +9,7 @@ Semantic Versioning: MAJOR.MINOR.PATCH APP_VERSION = "0.9n" BUILD_DATE = "2026-04-05" -DB_SCHEMA_VERSION = "20260406c" # Migration 039 +DB_SCHEMA_VERSION = "20260406d" # Migration 040 MODULE_VERSIONS = { "auth": "1.2.0", @@ -24,20 +24,21 @@ MODULE_VERSIONS = { "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", - "admin": "1.2.0", + "admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full "stats": "1.0.1", "exportdata": "1.1.0", "importdata": "1.0.0", "membership": "2.1.0", "workflow": "0.6.0", # Phase 4: End Node Template Engine - "app_dashboard": "1.9.0", # product vs lab layout defaults; user dashboard configure page API fields + "app_dashboard": "1.10.0", # Produkt-Standard aus system_config; Response-Form unverändert } CHANGELOG = [ { "version": "0.9n", - "date": "2026-04-05", + "date": "2026-04-06", "changes": [ + "Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)", "Phase 4: End Node Template Engine", "workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)", "workflow_executor.py: execute_end_node() with Jinja2 template rendering", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index d02d77c..154a50a 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -241,6 +241,7 @@ function AppShell() { } /> } /> } /> + } /> }/> }/> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index 396e792..bb5cbc2 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -115,6 +115,11 @@ export const ADMIN_GROUPS = [ label: 'SMTP & Metadaten-Export', description: 'SMTP-Status, Test-Mail und Placeholder-Katalog (JSON/ZIP).', }, + { + to: '/admin/dashboard-product-default', + label: 'Produkt-Dashboard (Standard)', + description: 'Globales Standard-Layout der Startseite (DB oder Code-Fallback).', + }, ], }, ] diff --git a/frontend/src/pages/DashboardConfigurePage.jsx b/frontend/src/pages/DashboardConfigurePage.jsx index acbc81f..17e5337 100644 --- a/frontend/src/pages/DashboardConfigurePage.jsx +++ b/frontend/src/pages/DashboardConfigurePage.jsx @@ -32,10 +32,14 @@ function catalogMetaById(catalog) { return Object.fromEntries(catalog.widgets.map((w) => [w.id, w])) } -export default function DashboardConfigurePage() { +/** + * @param {{ adminMode?: boolean }} [props] + */ +export default function DashboardConfigurePage({ adminMode = false } = {}) { ensurePilotLabWidgetsRegistered() const [bundle, setBundle] = useState(null) + const [adminFromDatabase, setAdminFromDatabase] = useState(null) const [catalog, setCatalog] = useState(null) const [layout, setLayout] = useState(null) const [addPanelOpen, setAddPanelOpen] = useState(false) @@ -103,16 +107,29 @@ export default function DashboardConfigurePage() { const load = useCallback(async () => { setErr(null) try { - const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) - setCatalog(cat) - setBundle(b) - setChartDaysDraftByWidgetId({}) - const base = b.custom ? b.layout : structuredClone(b.product_default_layout) - setLayout(normalizeLayoutForEditor(base)) + if (adminMode) { + const [cat, d] = await Promise.all([ + api.adminGetWidgetsCatalogFull(), + api.adminGetDashboardProductDefault(), + ]) + setCatalog(cat) + setBundle({ custom: false, product_default_layout: d.layout }) + setAdminFromDatabase(!!d.from_database) + setChartDaysDraftByWidgetId({}) + setLayout(normalizeLayoutForEditor(structuredClone(d.layout))) + } else { + const [cat, b] = await Promise.all([api.getAppWidgetsCatalog(), api.getAppDashboardLayout()]) + setCatalog(cat) + setBundle(b) + setAdminFromDatabase(null) + setChartDaysDraftByWidgetId({}) + const base = b.custom ? b.layout : structuredClone(b.product_default_layout) + setLayout(normalizeLayoutForEditor(base)) + } } catch (e) { setErr(formatFastApiDetail(null, e.message)) } - }, []) + }, [adminMode]) useEffect(() => { load() @@ -138,8 +155,13 @@ export default function DashboardConfigurePage() { setMsg(null) setErr(null) try { - await api.putAppDashboardLayout(toSave) - setMsg('Dein Dashboard wurde gespeichert.') + if (adminMode) { + await api.adminPutDashboardProductDefault(toSave) + setMsg('System-Standard für die Übersicht wurde gespeichert.') + } else { + await api.putAppDashboardLayout(toSave) + setMsg('Dein Dashboard wurde gespeichert.') + } await load() } catch (e) { setErr(formatFastApiDetail(null, e.message)) @@ -149,15 +171,27 @@ export default function DashboardConfigurePage() { } const resetToSystem = async () => { - if (!window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?')) return + const ok = adminMode + ? window.confirm( + 'Eintrag in der Datenbank löschen und Layout aus dem Code (widget_catalog) wiederherstellen?' + ) + : window.confirm('Dein individuelles Layout löschen und System-Standard wiederherstellen?') + if (!ok) return setBusy(true) setMsg(null) setErr(null) try { - const r = await api.resetAppDashboardLayout() - setChartDaysDraftByWidgetId({}) - setLayout(normalizeLayoutForEditor(r.layout)) - setMsg('Auf System-Standard zurückgesetzt.') + if (adminMode) { + const r = await api.adminDeleteDashboardProductDefault() + setChartDaysDraftByWidgetId({}) + setLayout(normalizeLayoutForEditor(r.layout)) + setMsg('Code-Standard wiederhergestellt (kein DB-Override mehr).') + } else { + const r = await api.resetAppDashboardLayout() + setChartDaysDraftByWidgetId({}) + setLayout(normalizeLayoutForEditor(r.layout)) + setMsg('Auf System-Standard zurückgesetzt.') + } await load() } catch (e) { setErr(formatFastApiDetail(null, e.message)) @@ -253,21 +287,36 @@ export default function DashboardConfigurePage() {
- ← Einstellungen + {adminMode ? '← Basiseinstellungen (Admin)' : '← Einstellungen'}

- Übersicht anpassen + {adminMode ? 'Produkt-Übersicht: Systemstandard' : 'Übersicht anpassen'}

- Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt - im eigenen Fenster, ohne langes Scrollen. + {adminMode + ? 'Globales Standard-Dashboard für alle Nutzer ohne eigenes Layout. Gespeichert in der Datenbank; mit „Code-Standard wiederherstellen“ wird der Eintrag entfernt und der Fallback aus dem Code genutzt.' + : 'Kacheln für die Startseite sortieren und entfernen. Neue Kacheln über „Kachel hinzufügen“ – mit Suche direkt im eigenen Fenster, ohne langes Scrollen.'}

- {!bundle?.custom && ( + {adminMode && adminFromDatabase != null && ( +

+ {adminFromDatabase ? ( + <> + Aktuell gilt ein gespeicherter Systemstandard (Datenbank). + + ) : ( + <> + Es liegt kein DB-Override vor — es wird der Code-Standard aus dem Widget-Katalog + verwendet. + + )} +

+ )} + {!adminMode && !bundle?.custom && (

Du bearbeitest gerade das System-Standardlayout. Mit „Speichern“ legst du deine persönliche Version ab. @@ -457,10 +506,14 @@ export default function DashboardConfigurePage() { Speichern - - Zur Übersicht + + {adminMode ? 'Admin-Übersicht' : 'Zur Übersicht'}

diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 7782195..df6b86d 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -76,6 +76,13 @@ export const api = { putAppDashboardLayout: (layout) => req('/app/dashboard-layout', jput(layout)), resetAppDashboardLayout: () => req('/app/dashboard-layout/reset', { method: 'POST' }), + adminGetWidgetsCatalogFull: () => req('/admin/widgets/catalog-full'), + adminGetDashboardProductDefault: () => req('/admin/dashboard-product-default'), + adminPutDashboardProductDefault: (layout) => + req('/admin/dashboard-product-default', jput(layout)), + adminDeleteDashboardProductDefault: () => + req('/admin/dashboard-product-default', { method: 'DELETE' }), + // Persönliche Referenzwerte (Profil, historisch) listReferenceValueTypes: () => req('/reference-value-types'), listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),