feat: Introduce admin dashboard product standard management
All checks were successful
Deploy Development / deploy (push) Successful in 57s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 17s

- 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.
This commit is contained in:
Lars 2026-04-08 10:32:18 +02:00
parent ff95ef63c7
commit 365ce49c6a
12 changed files with 299 additions and 32 deletions

View File

@ -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,

View File

@ -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).

View File

@ -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);

View File

@ -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}

View File

@ -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}

View File

@ -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,),
)

View File

@ -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

View File

@ -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",

View File

@ -241,6 +241,7 @@ function AppShell() {
<Route path="g/:groupId" element={<AdminGroupHubPage />} />
<Route path="users" element={<AdminUsersPage />} />
<Route path="system" element={<AdminSystemPage />} />
<Route path="dashboard-product-default" element={<DashboardConfigurePage adminMode />} />
<Route path="tier-limits" element={<AdminTierLimitsPage/>}/>
<Route path="features" element={<AdminFeaturesPage/>}/>
<Route path="tiers" element={<AdminTiersPage/>}/>

View File

@ -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).',
},
],
},
]

View File

@ -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 {
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 {
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 {
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() {
<div style={{ paddingBottom: 96, textAlign: 'left', maxWidth: 720, margin: '0 auto' }}>
<div style={{ marginBottom: 20 }}>
<Link
to="/settings"
to={adminMode ? '/admin/g/system' : '/settings'}
className="btn btn-secondary"
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
>
Einstellungen
{adminMode ? '← Basiseinstellungen (Admin)' : '← Einstellungen'}
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<LayoutDashboard size={26} color="var(--accent)" />
Übersicht anpassen
{adminMode ? 'Produkt-Übersicht: Systemstandard' : 'Übersicht anpassen'}
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginTop: 8 }}>
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.'}
</p>
{!bundle?.custom && (
{adminMode && adminFromDatabase != null && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
{adminFromDatabase ? (
<>
Aktuell gilt ein <strong>gespeicherter Systemstandard</strong> (Datenbank).
</>
) : (
<>
Es liegt <strong>kein DB-Override</strong> vor es wird der Code-Standard aus dem Widget-Katalog
verwendet.
</>
)}
</p>
)}
{!adminMode && !bundle?.custom && (
<p style={{ fontSize: 12, color: 'var(--accent)', marginTop: 10, lineHeight: 1.5 }}>
Du bearbeitest gerade das <strong>System-Standardlayout</strong>. Mit Speichern legst du deine persönliche
Version ab.
@ -457,10 +506,14 @@ export default function DashboardConfigurePage() {
Speichern
</button>
<button type="button" className="btn btn-secondary" disabled={busy} onClick={resetToSystem}>
System-Standard wiederherstellen
{adminMode ? 'Code-Standard wiederherstellen' : 'System-Standard wiederherstellen'}
</button>
<Link to="/" className="btn btn-secondary" style={{ textDecoration: 'none', textAlign: 'center' }}>
Zur Übersicht
<Link
to={adminMode ? '/admin' : '/'}
className="btn btn-secondary"
style={{ textDecoration: 'none', textAlign: 'center' }}
>
{adminMode ? 'Admin-Übersicht' : 'Zur Übersicht'}
</Link>
</div>

View File

@ -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'),