diff --git a/backend/dashboard_widget_entitlements.py b/backend/dashboard_widget_entitlements.py index d1dd922..474cac5 100644 --- a/backend/dashboard_widget_entitlements.py +++ b/backend/dashboard_widget_entitlements.py @@ -9,6 +9,7 @@ import copy from typing import Any from widget_catalog import WIDGET_CATALOG +from widget_feature_requirements_db import get_widget_required_feature_ids def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict: @@ -21,13 +22,15 @@ _WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CAT def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool: - entry = _WIDGET_ENTRY_BY_ID.get(widget_id) - if entry is None: + if _WIDGET_ENTRY_BY_ID.get(widget_id) is None: return False - fid = entry.get("requires_feature") - if not fid: + fids = get_widget_required_feature_ids(widget_id, conn) + if not fids: return True - return bool(_check_feature_access(profile_id, fid, conn)["allowed"]) + for fid in fids: + if not _check_feature_access(profile_id, fid, conn)["allowed"]: + return False + return True def _public_row(entry: dict[str, Any], *, allowed: bool) -> dict[str, Any]: @@ -43,10 +46,7 @@ def widgets_catalog_for_profile(profile_id: str, conn) -> list[dict[str, Any]]: """Zeilen für GET /api/app/widgets/catalog (ohne internes requires_feature-Feld).""" out: list[dict[str, Any]] = [] for e in WIDGET_CATALOG: - fid = e.get("requires_feature") - allowed = True - if fid: - allowed = bool(_check_feature_access(profile_id, fid, conn)["allowed"]) + allowed = widget_id_allowed(e["id"], profile_id, conn) out.append(_public_row(e, allowed=allowed)) return out diff --git a/backend/migrations/041_widget_feature_requirements.sql b/backend/migrations/041_widget_feature_requirements.sql new file mode 100644 index 0000000..424c2d7 --- /dev/null +++ b/backend/migrations/041_widget_feature_requirements.sql @@ -0,0 +1,16 @@ +-- Dashboard-Widgets: explizite Feature-Anforderungen (Custom) statt nur widget_catalog.requires_feature +-- Kein Eintrag in dashboard_widget_requirement_custom → Anforderungen aus Code (Katalog). +-- Eintrag vorhanden → AND über alle zugeordneten features (0 Zeilen = kein Feature nötig). + +CREATE TABLE IF NOT EXISTS dashboard_widget_requirement_custom ( + widget_id VARCHAR(64) PRIMARY KEY, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS widget_feature_requirements ( + widget_id VARCHAR(64) NOT NULL REFERENCES dashboard_widget_requirement_custom (widget_id) ON DELETE CASCADE, + feature_id TEXT NOT NULL REFERENCES features (id) ON DELETE CASCADE, + PRIMARY KEY (widget_id, feature_id) +); + +CREATE INDEX IF NOT EXISTS idx_widget_feature_requirements_feature_id ON widget_feature_requirements (feature_id); diff --git a/backend/routers/admin.py b/backend/routers/admin.py index 871b2f2..940c5d4 100644 --- a/backend/routers/admin.py +++ b/backend/routers/admin.py @@ -14,8 +14,14 @@ 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_layout_schema import ALLOWED_WIDGET_IDS, DashboardLayoutPayload, product_default_layout_dict from dashboard_widget_entitlements import widgets_catalog_admin_payload +from widget_catalog import WIDGET_CATALOG +from widget_feature_requirements_db import ( + clear_custom_requirements, + fetch_assignments_bundle, + set_custom_requirements, +) from system_dashboard_product_default import ( delete_product_default_override, get_product_default_base_dict, @@ -213,3 +219,76 @@ def admin_delete_dashboard_product_default(session: dict = Depends(require_admin delete_product_default_override(conn) layout = get_product_default_base_dict(conn) return {"ok": True, "layout": layout, "from_database": False} + + +@router.get("/widget-feature-assignments") +def admin_list_widget_feature_assignments(session: dict = Depends(require_admin)): + """Alle Katalog-Widgets mit Custom-/Katalog-Feature-Listen zur Admin-Pflege.""" + _ = session + with get_db() as conn: + custom_ids, by_w = fetch_assignments_bundle(conn) + cur = get_cursor(conn) + cur.execute( + """ + SELECT id, name, category, active + FROM features + ORDER BY category NULLS LAST, name + """ + ) + features = [r2d(r) for r in cur.fetchall()] + widgets_out: list[dict[str, Any]] = [] + for e in WIDGET_CATALOG: + wid = e["id"] + cf = e.get("requires_feature") + code_fids = [cf] if cf else [] + uses_custom = wid in custom_ids + widgets_out.append( + { + "id": wid, + "title": e["title"], + "description": e["description"], + "uses_custom_requirements": uses_custom, + "feature_ids": list(by_w.get(wid, [])) if uses_custom else [], + "catalog_feature_ids": code_fids, + } + ) + return {"widgets": widgets_out, "features": features} + + +@router.put("/widget-feature-assignments/{widget_id}") +def admin_put_widget_feature_assignment( + widget_id: str, + body: dict[str, Any], + session: dict = Depends(require_admin), +): + """ + mode=catalog: Katalog-Fallback (requires_feature im Code). + mode=custom: AND über feature_ids (leere Liste = Widget ohne Feature-Gate). + """ + _ = session + if widget_id not in ALLOWED_WIDGET_IDS: + raise HTTPException(404, "Unbekanntes Widget") + mode = body.get("mode", "custom") + if mode == "catalog": + with get_db() as conn: + clear_custom_requirements(conn, widget_id) + return {"ok": True, "widget_id": widget_id, "uses_custom_requirements": False, "feature_ids": []} + if mode != "custom": + raise HTTPException(422, "mode muss catalog oder custom sein") + raw_ids = body.get("feature_ids") + if not isinstance(raw_ids, list): + raise HTTPException(422, "feature_ids muss Liste sein") + cleaned = [str(x).strip() for x in raw_ids if x is not None and str(x).strip()] + with get_db() as conn: + cur = get_cursor(conn) + for fid in cleaned: + cur.execute("SELECT id FROM features WHERE id = %s", (fid,)) + if not cur.fetchone(): + raise HTTPException(422, f"Unbekanntes Feature: {fid}") + set_custom_requirements(conn, widget_id, cleaned) + return { + "ok": True, + "widget_id": widget_id, + "uses_custom_requirements": True, + "feature_ids": cleaned, + } diff --git a/backend/tests/test_widget_feature_requirements_db.py b/backend/tests/test_widget_feature_requirements_db.py new file mode 100644 index 0000000..ac7ff39 --- /dev/null +++ b/backend/tests/test_widget_feature_requirements_db.py @@ -0,0 +1,47 @@ +from dashboard_widget_entitlements import widget_id_allowed + + +def test_multi_feature_and_requires_all(monkeypatch): + wid = "nutrition_detail_charts" + + def fake_check(pid, fid, conn): + return {"allowed": fid in ("a", "b")} + + monkeypatch.setattr( + "dashboard_widget_entitlements.get_widget_required_feature_ids", + lambda w, conn: ["a", "b"] if w == wid else [], + ) + monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", fake_check) + assert widget_id_allowed(wid, "p", object()) is True + + def fake_check_one_denied(pid, fid, conn): + return {"allowed": fid == "a"} + + monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", fake_check_one_denied) + assert widget_id_allowed(wid, "p", object()) is False + + +def test_no_features_required_always_allowed(monkeypatch): + monkeypatch.setattr( + "dashboard_widget_entitlements.get_widget_required_feature_ids", + lambda wid, conn: [], + ) + assert widget_id_allowed("welcome", "p", object()) is True + + +def test_unknown_widget_not_allowed(): + assert widget_id_allowed("not_in_catalog", "p", object()) is False + + +def test_get_widget_required_catalog_fallback(monkeypatch): + from widget_feature_requirements_db import get_widget_required_feature_ids + + class _Cur: + def execute(self, *a, **k): + pass + + def fetchone(self): + return None + + monkeypatch.setattr("widget_feature_requirements_db.get_cursor", lambda _c: _Cur()) + assert get_widget_required_feature_ids("quick_capture", object()) == ["weight_entries"] diff --git a/backend/version.py b/backend/version.py index 41d1c3c..abefb7c 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 = "20260406d" # Migration 040 +DB_SCHEMA_VERSION = "20260406e" # Migration 041 MODULE_VERSIONS = { "auth": "1.2.0", @@ -24,13 +24,13 @@ MODULE_VERSIONS = { "photos": "1.0.0", "insights": "1.3.0", "prompts": "1.1.0", - "admin": "1.3.0", # Dashboard Produkt-Standard (system_config) + catalog-full + "admin": "1.4.0", # Widget × Feature-Zuordnung (Migration 041) "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.10.0", # Produkt-Standard aus system_config; Response-Form unverändert + "app_dashboard": "1.11.0", # Entitlements: DB-Override widget→features (AND), sonst Katalog } CHANGELOG = [ @@ -38,6 +38,7 @@ CHANGELOG = [ "version": "0.9n", "date": "2026-04-06", "changes": [ + "Admin: Widgets × Features (Migration 041, AND-Gates, Hybrid mit widget_catalog)", "Admin: Produkt-Dashboard-Systemstandard (Migration 040 system_config, API, UI)", "Phase 4: End Node Template Engine", "workflow_models.py: EndNodeOutputMode enum (AUTO, TEMPLATE)", diff --git a/backend/widget_feature_requirements_db.py b/backend/widget_feature_requirements_db.py new file mode 100644 index 0000000..00a2dec --- /dev/null +++ b/backend/widget_feature_requirements_db.py @@ -0,0 +1,107 @@ +""" +DB-Override für Dashboard-Widget → Feature(s): AND-Semantik. + +Ohne Zeile in dashboard_widget_requirement_custom → Fallback auf widget_catalog.requires_feature. +Mit Marker-Zeile → nur widget_feature_requirements (0..n, leer = kein Feature erforderlich). +""" +from __future__ import annotations + +from typing import Any + +from db import get_cursor, get_db +from widget_catalog import WIDGET_CATALOG + +_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG} + + +def _catalog_feature_ids_for_widget(widget_id: str) -> list[str]: + entry = _WIDGET_ENTRY_BY_ID.get(widget_id) + if not entry: + return [] + fid = entry.get("requires_feature") + return [fid] if fid else [] + + +def get_widget_required_feature_ids(widget_id: str, conn: Any | None) -> list[str]: + """Liste der Feature-IDs, die alle erlaubt sein müssen (AND). Leer = ohne Feature-Gate.""" + + def _query(c: Any) -> list[str]: + cur = get_cursor(c) + cur.execute( + "SELECT 1 FROM dashboard_widget_requirement_custom WHERE widget_id = %s", + (widget_id,), + ) + if cur.fetchone() is None: + return _catalog_feature_ids_for_widget(widget_id) + cur.execute( + """ + SELECT feature_id FROM widget_feature_requirements + WHERE widget_id = %s + ORDER BY feature_id + """, + (widget_id,), + ) + return [row["feature_id"] for row in cur.fetchall()] + + if conn is not None: + return _query(conn) + with get_db() as c: + return _query(c) + + +def fetch_assignments_bundle(conn: Any) -> tuple[set[str], dict[str, list[str]]]: + """ + Alle Custom-Marker und Junction-Zeilen für Admin-GET (ein Roundtrip). + Returns (custom_widget_ids, feature_ids_by_widget). + """ + cur = get_cursor(conn) + cur.execute("SELECT widget_id FROM dashboard_widget_requirement_custom") + custom_ids = {row["widget_id"] for row in cur.fetchall()} + cur.execute( + """ + SELECT widget_id, feature_id FROM widget_feature_requirements + ORDER BY widget_id, feature_id + """ + ) + by_w: dict[str, list[str]] = {} + for row in cur.fetchall(): + by_w.setdefault(row["widget_id"], []).append(row["feature_id"]) + return custom_ids, by_w + + +def set_custom_requirements(conn: Any, widget_id: str, feature_ids: list[str]) -> None: + """Custom aktivieren und Anforderungen ersetzen (dedupliziert).""" + seen: set[str] = set() + unique: list[str] = [] + for fid in feature_ids: + if fid and fid not in seen: + seen.add(fid) + unique.append(fid) + + cur = get_cursor(conn) + cur.execute( + """ + INSERT INTO dashboard_widget_requirement_custom (widget_id) + VALUES (%s) + ON CONFLICT (widget_id) DO UPDATE SET updated_at = CURRENT_TIMESTAMP + """, + (widget_id,), + ) + cur.execute("DELETE FROM widget_feature_requirements WHERE widget_id = %s", (widget_id,)) + for fid in unique: + cur.execute( + """ + INSERT INTO widget_feature_requirements (widget_id, feature_id) + VALUES (%s, %s) + """, + (widget_id, fid), + ) + + +def clear_custom_requirements(conn: Any, widget_id: str) -> None: + """Zurück auf Katalog-Fallback.""" + cur = get_cursor(conn) + cur.execute( + "DELETE FROM dashboard_widget_requirement_custom WHERE widget_id = %s", + (widget_id,), + ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 154a50a..e4cfbbd 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -40,6 +40,7 @@ import AdminPromptsPage from './pages/AdminPromptsPage' import AdminGoalTypesPage from './pages/AdminGoalTypesPage' import AdminFocusAreasPage from './pages/AdminFocusAreasPage' import AdminReferenceValueTypesPage from './pages/AdminReferenceValueTypesPage' +import AdminWidgetFeatureAssignmentsPage from './pages/AdminWidgetFeatureAssignmentsPage' import AdminHomePage from './pages/AdminHomePage' import AdminUsersPage from './pages/AdminUsersPage' import AdminSystemPage from './pages/AdminSystemPage' @@ -247,6 +248,7 @@ function AppShell() { }/> }/> }/> + } /> }/> }/> }/> diff --git a/frontend/src/config/adminNav.js b/frontend/src/config/adminNav.js index bb5cbc2..0d15fd0 100644 --- a/frontend/src/config/adminNav.js +++ b/frontend/src/config/adminNav.js @@ -38,6 +38,11 @@ export const ADMIN_GROUPS = [ label: 'User-Overrides', description: 'Individuelle Feature-Limits setzen.', }, + { + to: '/admin/widget-features', + label: 'Widgets × Features', + description: 'Dashboard-Widgets den Registry-Features zuordnen (AND).', + }, ], }, { diff --git a/frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx b/frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx new file mode 100644 index 0000000..815b9b9 --- /dev/null +++ b/frontend/src/pages/AdminWidgetFeatureAssignmentsPage.jsx @@ -0,0 +1,224 @@ +import { Fragment, useCallback, useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { LayoutGrid } from 'lucide-react' +import { api, formatFastApiDetail } from '../utils/api' + +export default function AdminWidgetFeatureAssignmentsPage() { + const [bundle, setBundle] = useState(null) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + const [expandedId, setExpandedId] = useState(null) + const [localFeatureIds, setLocalFeatureIds] = useState([]) + const [saving, setSaving] = useState(false) + + const load = useCallback(async () => { + setError('') + try { + const d = await api.adminGetWidgetFeatureAssignments() + setBundle(d) + } catch (e) { + setError(formatFastApiDetail(null, e.message)) + } + }, []) + + useEffect(() => { + load() + }, [load]) + + const openRow = (w) => { + setExpandedId(w.id) + setSuccess('') + setError('') + const base = w.uses_custom_requirements ? w.feature_ids : w.catalog_feature_ids + setLocalFeatureIds([...(base || [])]) + } + + const toggleFeature = (fid) => { + setLocalFeatureIds((prev) => { + const s = new Set(prev) + if (s.has(fid)) s.delete(fid) + else s.add(fid) + return [...s].sort() + }) + } + + const saveCatalog = async (widgetId) => { + setSaving(true) + setError('') + setSuccess('') + try { + await api.adminPutWidgetFeatureAssignment(widgetId, { mode: 'catalog' }) + setSuccess('Auf Katalog-Fallback zurückgesetzt.') + setExpandedId(null) + await load() + } catch (e) { + setError(formatFastApiDetail(null, e.message)) + } finally { + setSaving(false) + } + } + + const saveCustom = async (widgetId) => { + setSaving(true) + setError('') + setSuccess('') + try { + await api.adminPutWidgetFeatureAssignment(widgetId, { + mode: 'custom', + feature_ids: localFeatureIds, + }) + setSuccess('Feature-Zuordnung gespeichert (AND: alle müssen erlaubt sein).') + await load() + } catch (e) { + setError(formatFastApiDetail(null, e.message)) + } finally { + setSaving(false) + } + } + + if (!bundle && !error) { + return ( +
+
+
+ ) + } + + return ( +
+ + ← Features (Admin) + +

+ + Widgets × Features +

+

+ Ordnet jedes Dashboard-Widget einer oder mehreren Features aus der Registry zu. Ohne Eintrag gilt der + Vorgabewert aus dem Code-Katalog (requires_feature). Mit Custom müssen{' '} + alle gewählten Features für den Nutzer erlaubt sein. Leere Auswahl = Widget ohne + Feature-Gate. +

+ {error && ( +

+ {error} +

+ )} + {success && ( +

+ {success} +

+ )} + +
+ + + + + + + + + {bundle?.widgets?.map((w) => { + const expanded = expandedId === w.id + const summary = w.uses_custom_requirements + ? `Custom: ${w.feature_ids.length ? w.feature_ids.join(', ') : '— (kein Feature)'}` + : `Katalog: ${w.catalog_feature_ids.length ? w.catalog_feature_ids.join(', ') : '—'}` + return ( + + + + + + + {expanded && ( + + + + )} + + ) + })} + +
WidgetModus / Anforderungen +
+
{w.title}
+
{w.id}
+
+
{summary}
+ {w.description && ( +
{w.description}
+ )} +
+ +
+
Features (mehrfach, AND)
+
+ {(bundle.features || []).map((f) => ( + + ))} +
+
+ + +
+
+
+
+ ) +} diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index df6b86d..9f18fe7 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -83,6 +83,10 @@ export const api = { adminDeleteDashboardProductDefault: () => req('/admin/dashboard-product-default', { method: 'DELETE' }), + adminGetWidgetFeatureAssignments: () => req('/admin/widget-feature-assignments'), + adminPutWidgetFeatureAssignment: (widgetId, body) => + req(`/admin/widget-feature-assignments/${encodeURIComponent(widgetId)}`, jput(body)), + // Persönliche Referenzwerte (Profil, historisch) listReferenceValueTypes: () => req('/reference-value-types'), listReferenceValueMetaEnums: () => req('/reference-value-meta/enums'),