feat: Update widget catalog and enhance dashboard layout features
- Added new "Dashboard-Lab-Widgets" entry to the documentation for better guidance on widget configuration. - Updated the app_dashboard version to 1.8.0 to reflect the introduction of widget catalog features and layout entitlements. - Enhanced widget catalog entries to include optional feature requirements for better visibility and access control. - Improved the DashboardLabPage to manage widget visibility based on feature entitlements, ensuring a more tailored user experience.
This commit is contained in:
parent
bc91396885
commit
9bc0cf70da
|
|
@ -7,6 +7,7 @@
|
|||
> | Coding-Regeln | `.claude/rules/CODING_RULES.md` |
|
||||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | **GUI / IA / Admin / Nav / PWA-Leiste** | **`docs/issues/GUI_IA_ADMIN_NAV_2026-04-05.md`** |
|
||||
> | **Dashboard-Lab-Widgets** (Katalog, Registrierung, `config`) | **`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`** |
|
||||
|
||||
## Claude Code Verantwortlichkeiten
|
||||
|
||||
|
|
@ -842,6 +843,7 @@ Bottom-Padding Mobile: 80px (Navigation)
|
|||
|Auth-Flow|`.claude/library/AUTH.md`|Sicherheit + Sessions|
|
||||
|API-Referenz|`.claude/library/API\_REFERENCE.md`|Alle Endpoints|
|
||||
|Datenbankschema|`.claude/library/DATABASE.md`|Tabellen + Beziehungen|
|
||||
|Dashboard-Lab-Widgets|`.claude/docs/technical/DASHBOARD_WIDGETS_AGENT_GUIDE.md`|Katalog, Validierung, Frontend-Registry, konfigurierbare `config`|
|
||||
|
||||
> Library-Dateien werden mit `/document` generiert und nach größeren
|
||||
> Änderungen aktualisiert.
|
||||
|
|
|
|||
79
backend/dashboard_widget_entitlements.py
Normal file
79
backend/dashboard_widget_entitlements.py
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
"""
|
||||
Dashboard-Widgets × Feature-System: Sichtbarkeit aus check_feature_access.
|
||||
|
||||
Katalog-Einträge optional `requires_feature` (features.id). Fehlt der Key → immer erlaubt.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
from typing import Any
|
||||
|
||||
from widget_catalog import WIDGET_CATALOG
|
||||
|
||||
|
||||
def _check_feature_access(profile_id: str, feature_id: str, conn) -> dict:
|
||||
"""Indirection für Tests (monkeypatch) und spätes Laden von auth (bcrypt)."""
|
||||
from auth import check_feature_access
|
||||
|
||||
return check_feature_access(profile_id, feature_id, conn)
|
||||
|
||||
_WIDGET_ENTRY_BY_ID: dict[str, dict[str, Any]] = {e["id"]: e for e in WIDGET_CATALOG}
|
||||
|
||||
|
||||
def widget_id_allowed(widget_id: str, profile_id: str, conn) -> bool:
|
||||
entry = _WIDGET_ENTRY_BY_ID.get(widget_id)
|
||||
if entry is None:
|
||||
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]:
|
||||
return {
|
||||
"id": entry["id"],
|
||||
"title": entry["title"],
|
||||
"description": entry["description"],
|
||||
"allowed": allowed,
|
||||
}
|
||||
|
||||
|
||||
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"])
|
||||
out.append(_public_row(e, allowed=allowed))
|
||||
return out
|
||||
|
||||
|
||||
def widgets_catalog_payload(profile_id: str, conn) -> dict[str, Any]:
|
||||
return {
|
||||
"catalog_version": 1,
|
||||
"widgets": widgets_catalog_for_profile(profile_id, conn),
|
||||
}
|
||||
|
||||
|
||||
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).
|
||||
"""
|
||||
out = copy.deepcopy(layout)
|
||||
widgets = out.get("widgets") or []
|
||||
for w in widgets:
|
||||
wid = w.get("id")
|
||||
if not wid:
|
||||
continue
|
||||
if w.get("enabled") and not widget_id_allowed(wid, profile_id, conn):
|
||||
w["enabled"] = False
|
||||
if not any(w.get("enabled") for w in widgets):
|
||||
for w in widgets:
|
||||
if w.get("id") == "welcome":
|
||||
w["enabled"] = True
|
||||
break
|
||||
return out
|
||||
|
||||
|
|
@ -10,18 +10,23 @@ from psycopg2.extras import Json
|
|||
|
||||
from auth import require_auth
|
||||
from dashboard_layout_schema import DashboardLayoutPayload, coalesce_effective_layout, 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 widget_catalog import catalog_response
|
||||
|
||||
router = APIRouter(prefix="/api/app", tags=["app-dashboard-lab"])
|
||||
|
||||
|
||||
@router.get("/widgets/catalog")
|
||||
def get_widgets_catalog(session: dict = Depends(require_auth)) -> dict[str, Any]:
|
||||
"""Metadaten aller registrierbaren Dashboard-Widgets (IDs, Titel)."""
|
||||
def get_widgets_catalog(
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
session: dict = Depends(require_auth),
|
||||
) -> dict[str, Any]:
|
||||
"""Katalog inkl. allowed pro Widget (Feature / Subscription, effektiver Tier)."""
|
||||
_ = session
|
||||
return catalog_response()
|
||||
pid = get_pid(x_profile_id)
|
||||
with get_db() as conn:
|
||||
return widgets_catalog_payload(pid, conn)
|
||||
|
||||
|
||||
@router.get("/dashboard-layout")
|
||||
|
|
@ -40,10 +45,13 @@ def get_dashboard_layout(
|
|||
row = cur.fetchone()
|
||||
raw = row["dashboard_layout"] if row else None
|
||||
custom, effective = coalesce_effective_layout(raw)
|
||||
with get_db() as conn:
|
||||
effective = apply_entitlements_to_layout_dict(effective, pid, conn)
|
||||
default_adj = apply_entitlements_to_layout_dict(default_layout_dict(), pid, conn)
|
||||
return {
|
||||
"custom": custom,
|
||||
"layout": effective,
|
||||
"default_layout": default_layout_dict(),
|
||||
"default_layout": default_adj,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -59,6 +67,12 @@ def put_dashboard_layout(
|
|||
payload = DashboardLayoutPayload.model_validate(body)
|
||||
except Exception as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
with get_db() as conn:
|
||||
adjusted = apply_entitlements_to_layout_dict(payload.to_stored_dict(), pid, conn)
|
||||
try:
|
||||
payload = DashboardLayoutPayload.model_validate(adjusted)
|
||||
except Exception as e:
|
||||
raise HTTPException(422, str(e)) from e
|
||||
stored = payload.to_stored_dict()
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
|
|
|
|||
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
57
backend/tests/test_dashboard_widget_entitlements.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from dashboard_layout_schema import DashboardLayoutPayload
|
||||
from dashboard_widget_entitlements import apply_entitlements_to_layout_dict, widget_id_allowed
|
||||
|
||||
|
||||
def test_apply_entitlements_disables_widget_without_access(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"dashboard_widget_entitlements.widget_id_allowed",
|
||||
lambda wid, pid, conn: wid != "nutrition_detail_charts",
|
||||
)
|
||||
raw = {
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{"id": "welcome", "enabled": True},
|
||||
{"id": "nutrition_detail_charts", "enabled": True},
|
||||
],
|
||||
}
|
||||
out = apply_entitlements_to_layout_dict(raw, "p", None)
|
||||
assert {w["id"]: w["enabled"] for w in out["widgets"]} == {
|
||||
"welcome": True,
|
||||
"nutrition_detail_charts": False,
|
||||
}
|
||||
|
||||
|
||||
def test_apply_entitlements_leaves_welcome_on_when_all_blocked(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"dashboard_widget_entitlements.widget_id_allowed",
|
||||
lambda wid, pid, conn: False,
|
||||
)
|
||||
raw = {
|
||||
"version": 1,
|
||||
"widgets": [
|
||||
{"id": "welcome", "enabled": False},
|
||||
{"id": "nutrition_detail_charts", "enabled": False},
|
||||
],
|
||||
}
|
||||
out = apply_entitlements_to_layout_dict(raw, "p", None)
|
||||
assert any(w["id"] == "welcome" and w["enabled"] for w in out["widgets"])
|
||||
|
||||
|
||||
def test_widget_id_allowed_false_for_unknown_id():
|
||||
assert widget_id_allowed("not-a-widget", "p", None) is False
|
||||
|
||||
|
||||
def test_full_default_layout_still_validates_after_entitlements(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"dashboard_widget_entitlements.widget_id_allowed",
|
||||
lambda wid, pid, conn: wid != "ai_pipeline_insight",
|
||||
)
|
||||
from dashboard_layout_schema import default_layout_dict
|
||||
|
||||
d = default_layout_dict()
|
||||
d["widgets"] = [{**x, "enabled": x["id"] == "ai_pipeline_insight"} for x in d["widgets"]]
|
||||
adj = apply_entitlements_to_layout_dict(d, "p", None)
|
||||
p2 = DashboardLayoutPayload.model_validate(adj)
|
||||
ai = next(w for w in p2.widgets if w.id == "ai_pipeline_insight")
|
||||
assert ai.enabled is False
|
||||
assert any(w.enabled for w in p2.widgets)
|
||||
|
|
@ -1,7 +1,8 @@
|
|||
"""Widget-Katalog: Konsistenz (IDs, Default-Layout, Katalog-Response)."""
|
||||
|
||||
from dashboard_layout_schema import default_layout_dict
|
||||
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG, catalog_response
|
||||
from dashboard_widget_entitlements import widgets_catalog_payload
|
||||
from widget_catalog import ALLOWED_WIDGET_IDS, DEFAULT_LAB_WIDGET_IDS, WIDGET_CATALOG
|
||||
|
||||
|
||||
def test_catalog_ids_unique_and_match_allowed():
|
||||
|
|
@ -20,8 +21,27 @@ def test_default_layout_follows_catalog_order():
|
|||
assert any(w["enabled"] for w in d["widgets"])
|
||||
|
||||
|
||||
def test_catalog_response_shape():
|
||||
r = catalog_response()
|
||||
def test_catalog_payload_shape(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"dashboard_widget_entitlements._check_feature_access",
|
||||
lambda *args, **kwargs: {"allowed": True},
|
||||
)
|
||||
r = widgets_catalog_payload("test-profile", None)
|
||||
assert r["catalog_version"] == 1
|
||||
assert len(r["widgets"]) == len(WIDGET_CATALOG)
|
||||
assert {w["id"] for w in r["widgets"]} == ALLOWED_WIDGET_IDS
|
||||
for w in r["widgets"]:
|
||||
assert set(w.keys()) == {"id", "title", "description", "allowed"}
|
||||
assert w["allowed"] is True
|
||||
|
||||
|
||||
def test_catalog_marks_disallowed_when_feature_blocks(monkeypatch):
|
||||
def _check(_pid, feature_id, conn=None):
|
||||
return {"allowed": feature_id != "nutrition_entries"}
|
||||
|
||||
monkeypatch.setattr("dashboard_widget_entitlements._check_feature_access", _check)
|
||||
r = widgets_catalog_payload("p", None)
|
||||
by_id = {w["id"]: w for w in r["widgets"]}
|
||||
assert by_id["welcome"]["allowed"] is True
|
||||
assert by_id["nutrition_detail_charts"]["allowed"] is False
|
||||
assert by_id["body_overview"]["allowed"] is True
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ MODULE_VERSIONS = {
|
|||
"importdata": "1.0.0",
|
||||
"membership": "2.1.0",
|
||||
"workflow": "0.6.0", # Phase 4: End Node Template Engine
|
||||
"app_dashboard": "1.7.0", # nutrition_detail_charts, recovery_charts_panel, progress_photos
|
||||
"app_dashboard": "1.8.0", # widget catalog allowed via features; layout entitlements on GET/PUT
|
||||
}
|
||||
|
||||
CHANGELOG = [
|
||||
|
|
|
|||
|
|
@ -6,13 +6,16 @@ Frontend-Komponenten registrieren dieselben IDs lokal (siehe widgetSystem/regist
|
|||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, TypedDict
|
||||
from typing import Any, NotRequired, TypedDict
|
||||
|
||||
|
||||
class WidgetCatalogEntry(TypedDict):
|
||||
"""requires_feature: optional features.id; fehlt oder leer → Widget immer sichtbar (nur Auth)."""
|
||||
|
||||
id: str
|
||||
title: str
|
||||
description: str
|
||||
requires_feature: NotRequired[str]
|
||||
|
||||
|
||||
# Reihenfolge = Default-Layout-Reihenfolge. Aktiv-Flags: DEFAULT_LAB_WIDGET_IDS (Rest zunächst aus).
|
||||
|
|
@ -25,7 +28,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "quick_capture",
|
||||
"title": "Schnelleingabe",
|
||||
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus)",
|
||||
"description": "Gewicht + Baseline-Vitals; optional show_weight / show_resting_hr / show_hrv / show_vo2_max (false = aus); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "kpi_board",
|
||||
|
|
@ -35,12 +39,14 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "body_overview",
|
||||
"title": "Körper (Chart)",
|
||||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90)",
|
||||
"description": "Gewicht & Kennzahlen (optional: config chart_days 7–90); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "activity_overview",
|
||||
"title": "Aktivität",
|
||||
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90",
|
||||
"description": "Trainingstyp-Verteilung (Kuchen) + Konsistenz — Zeitraum über config chart_days 7–90; Feature activity_entries",
|
||||
"requires_feature": "activity_entries",
|
||||
},
|
||||
{
|
||||
"id": "dashboard_greeting",
|
||||
|
|
@ -50,17 +56,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "quick_weight_today",
|
||||
"title": "Gewicht heute",
|
||||
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard)",
|
||||
"description": "Tagesgewicht erfassen (wie Produkt-Dashboard); Feature weight_entries",
|
||||
"requires_feature": "weight_entries",
|
||||
},
|
||||
{
|
||||
"id": "body_stat_strip",
|
||||
"title": "Kennzahlen-Kacheln",
|
||||
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe",
|
||||
"description": "Gewicht, KF, Magermasse, Ø-kcal — Oberreihe; u. a. nutrition_entries (Ø-kcal)",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "status_pills",
|
||||
"title": "Indikatoren (Pills)",
|
||||
"description": "WHR, WHtR, Protein, KF",
|
||||
"description": "WHR, WHtR, Protein, KF; Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "profile_goals_progress",
|
||||
|
|
@ -70,17 +79,20 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "trend_kcal_weight",
|
||||
"title": "Trend Kalorien + Gewicht",
|
||||
"description": "Linienchart (optional config chart_days 7–90, Default 30)",
|
||||
"description": "Linienchart (optional config chart_days 7–90, Default 30); Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "nutrition_activity_summary",
|
||||
"title": "Ernährung & Aktivität Kurz",
|
||||
"description": "Ø 7T Kacheln",
|
||||
"description": "Ø 7T Kacheln; Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "nutrition_detail_charts",
|
||||
"title": "Ernährung — Detaillierte Charts",
|
||||
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30)",
|
||||
"description": "Phase-0c NutritionCharts (optional chart_days 7–90, Default 30); Feature nutrition_entries",
|
||||
"requires_feature": "nutrition_entries",
|
||||
},
|
||||
{
|
||||
"id": "recovery_charts_panel",
|
||||
|
|
@ -90,7 +102,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "progress_photos",
|
||||
"title": "Fortschrittsfotos",
|
||||
"description": "Galerie der hochgeladenen Fotos",
|
||||
"description": "Galerie der hochgeladenen Fotos; Feature photos",
|
||||
"requires_feature": "photos",
|
||||
},
|
||||
{
|
||||
"id": "recovery_sleep_rest",
|
||||
|
|
@ -105,7 +118,8 @@ WIDGET_CATALOG: list[WidgetCatalogEntry] = [
|
|||
{
|
||||
"id": "ai_pipeline_insight",
|
||||
"title": "KI Pipeline & letzte Analyse",
|
||||
"description": "Pipeline starten + Gesamt-Insight",
|
||||
"description": "Pipeline starten + Gesamt-Insight; Feature ai_pipeline",
|
||||
"requires_feature": "ai_pipeline",
|
||||
},
|
||||
]
|
||||
|
||||
|
|
@ -120,11 +134,3 @@ DEFAULT_LAB_WIDGET_IDS: frozenset[str] = frozenset(
|
|||
)
|
||||
|
||||
ALLOWED_WIDGET_IDS: frozenset[str] = frozenset(e["id"] for e in WIDGET_CATALOG)
|
||||
|
||||
|
||||
def catalog_response() -> dict[str, Any]:
|
||||
"""Payload für GET /api/app/widgets/catalog."""
|
||||
return {
|
||||
"catalog_version": 1,
|
||||
"widgets": list(WIDGET_CATALOG),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react'
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { ChevronDown, ChevronUp, LayoutGrid } from 'lucide-react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { api, formatFastApiDetail } from '../utils/api'
|
||||
|
|
@ -43,6 +43,35 @@ export default function DashboardLabPage() {
|
|||
|
||||
const metaById = catalogMetaById(catalog)
|
||||
|
||||
const isWidgetCatalogAllowed = useCallback(
|
||||
(widgetId) => {
|
||||
const m = metaById[widgetId]
|
||||
if (m == null) return true
|
||||
return m.allowed !== false
|
||||
},
|
||||
[metaById],
|
||||
)
|
||||
|
||||
const visibleEditorIndices = useMemo(
|
||||
() =>
|
||||
layout?.widgets?.map((_, i) => i).filter((i) => isWidgetCatalogAllowed(layout.widgets[i].id)) ?? [],
|
||||
[layout, isWidgetCatalogAllowed],
|
||||
)
|
||||
|
||||
const layoutForPreview = useMemo(
|
||||
() =>
|
||||
layout
|
||||
? {
|
||||
...layout,
|
||||
widgets: layout.widgets.map((w) => ({
|
||||
...w,
|
||||
enabled: w.enabled && isWidgetCatalogAllowed(w.id),
|
||||
})),
|
||||
}
|
||||
: null,
|
||||
[layout, isWidgetCatalogAllowed],
|
||||
)
|
||||
|
||||
const commitChartDaysDraftToLayout = useCallback((draftStr, baseLayout, widgetId) => {
|
||||
const clamped = normalizeBodyChartDays(
|
||||
draftStr === '' || draftStr == null ? BODY_CHART_DAYS_DEFAULT : draftStr
|
||||
|
|
@ -189,7 +218,8 @@ export default function DashboardLabPage() {
|
|||
{err && <p style={{ fontSize: 12, color: '#D85A30', marginBottom: 8 }}>{err}</p>}
|
||||
{msg && <p style={{ fontSize: 12, color: 'var(--accent)', marginBottom: 8 }}>{msg}</p>}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: '0 0 12px' }}>
|
||||
{layout.widgets.map((w, i) => {
|
||||
{visibleEditorIndices.map((i) => {
|
||||
const w = layout.widgets[i]
|
||||
const label = metaById[w.id]?.title || w.id
|
||||
const chartDaysVal =
|
||||
w.config?.chart_days != null
|
||||
|
|
@ -361,7 +391,9 @@ export default function DashboardLabPage() {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
<WidgetRenderer layout={layout} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
{layoutForPreview && (
|
||||
<WidgetRenderer layout={layoutForPreview} refreshTick={refreshTick} requestRefresh={requestRefresh} />
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user