feat: Implement widget-feature assignment management in admin dashboard
All checks were successful
Deploy Development / deploy (push) Successful in 53s
Build Test / lint-backend (push) Successful in 0s
Build Test / build-frontend (push) Successful in 15s

- Added new API endpoints for listing and updating widget-feature assignments, allowing for custom feature requirements.
- Introduced a new admin page for managing widget-feature assignments, enhancing the admin interface.
- Updated navigation to include a link to the new widget-feature assignments page.
- Refactored widget access logic to support AND-based feature requirements for widgets.
- Bumped app_dashboard version to 1.11.0 to reflect these changes and improvements.
This commit is contained in:
Lars 2026-04-08 12:26:28 +02:00
parent 365ce49c6a
commit 24daeeb83c
10 changed files with 498 additions and 13 deletions

View File

@ -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
fids = get_widget_required_feature_ids(widget_id, conn)
if not fids:
return True
for fid in fids:
if not _check_feature_access(profile_id, fid, conn)["allowed"]:
return False
fid = entry.get("requires_feature")
if not fid:
return True
return bool(_check_feature_access(profile_id, fid, conn)["allowed"])
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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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() {
<Route path="tiers" element={<AdminTiersPage/>}/>
<Route path="coupons" element={<AdminCouponsPage/>}/>
<Route path="user-restrictions" element={<AdminUserRestrictionsPage/>}/>
<Route path="widget-features" element={<AdminWidgetFeatureAssignmentsPage />} />
<Route path="training-types" element={<AdminTrainingTypesPage/>}/>
<Route path="activity-mappings" element={<AdminActivityMappingsPage/>}/>
<Route path="training-profiles" element={<AdminTrainingProfiles/>}/>

View File

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

View File

@ -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 (
<div style={{ padding: 48, textAlign: 'center' }}>
<div className="spinner" style={{ width: 32, height: 32, margin: '0 auto' }} />
</div>
)
}
return (
<div style={{ padding: '0 16px 96px', maxWidth: 920, margin: '0 auto', textAlign: 'left' }}>
<Link
to="/admin/g/features"
className="btn btn-secondary"
style={{ display: 'inline-flex', marginBottom: 12, textDecoration: 'none' }}
>
Features (Admin)
</Link>
<h1 className="page-title" style={{ display: 'flex', alignItems: 'center', gap: 10 }}>
<LayoutGrid size={26} color="var(--accent)" />
Widgets × Features
</h1>
<p style={{ fontSize: 13, color: 'var(--text2)', lineHeight: 1.6, marginBottom: 16 }}>
Ordnet jedes Dashboard-Widget einer oder mehreren Features aus der Registry zu. Ohne Eintrag gilt der
Vorgabewert aus dem Code-Katalog (<code>requires_feature</code>). Mit <strong>Custom</strong> müssen{' '}
<strong>alle</strong> gewählten Features für den Nutzer erlaubt sein. Leere Auswahl = Widget ohne
Feature-Gate.
</p>
{error && (
<p className="card section-gap" style={{ color: '#D85A30', padding: 12 }}>
{error}
</p>
)}
{success && (
<p className="card section-gap" style={{ color: 'var(--accent)', padding: 12 }}>
{success}
</p>
)}
<div className="card section-gap" style={{ overflowX: 'auto' }}>
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: 13 }}>
<thead>
<tr style={{ textAlign: 'left', borderBottom: '1px solid var(--border)' }}>
<th style={{ padding: '10px 8px' }}>Widget</th>
<th style={{ padding: '10px 8px' }}>Modus / Anforderungen</th>
<th style={{ padding: '10px 8px' }} />
</tr>
</thead>
<tbody>
{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 (
<Fragment key={w.id}>
<tr style={{ borderBottom: '1px solid var(--border)' }}>
<td style={{ padding: '12px 8px', verticalAlign: 'top' }}>
<div style={{ fontWeight: 600 }}>{w.title}</div>
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 4 }}>{w.id}</div>
</td>
<td style={{ padding: '12px 8px', verticalAlign: 'top' }}>
<div style={{ fontSize: 12 }}>{summary}</div>
{w.description && (
<div style={{ fontSize: 11, color: 'var(--text3)', marginTop: 6 }}>{w.description}</div>
)}
</td>
<td style={{ padding: '12px 8px', verticalAlign: 'top', whiteSpace: 'nowrap' }}>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: 12 }}
onClick={() => (expanded ? setExpandedId(null) : openRow(w))}
>
{expanded ? 'Schließen' : 'Bearbeiten'}
</button>
</td>
</tr>
{expanded && (
<tr style={{ background: 'var(--surface2)' }}>
<td colSpan={3} style={{ padding: 16 }}>
<div style={{ fontWeight: 600, marginBottom: 10 }}>Features (mehrfach, AND)</div>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: 8,
marginBottom: 16,
}}
>
{(bundle.features || []).map((f) => (
<label
key={f.id}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: 8,
fontSize: 12,
cursor: 'pointer',
}}
>
<input
type="checkbox"
checked={localFeatureIds.includes(f.id)}
onChange={() => toggleFeature(f.id)}
disabled={saving}
/>
<span>
<span style={{ fontWeight: 500 }}>{f.name}</span>
<span style={{ color: 'var(--text3)', marginLeft: 6 }}>({f.id})</span>
{!f.active && (
<span style={{ color: 'var(--text3)', display: 'block' }}>inaktiv</span>
)}
</span>
</label>
))}
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8 }}>
<button
type="button"
className="btn btn-primary"
disabled={saving}
onClick={() => saveCustom(w.id)}
>
Custom speichern
</button>
<button
type="button"
className="btn btn-secondary"
disabled={saving}
onClick={() => saveCatalog(w.id)}
>
Katalog-Fallback
</button>
</div>
</td>
</tr>
)}
</Fragment>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

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