diff --git a/CLAUDE.md b/CLAUDE.md index 60e3b6d..aa859e2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/backend/dashboard_widget_entitlements.py b/backend/dashboard_widget_entitlements.py new file mode 100644 index 0000000..2cfc853 --- /dev/null +++ b/backend/dashboard_widget_entitlements.py @@ -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 + diff --git a/backend/routers/app_dashboard.py b/backend/routers/app_dashboard.py index 3598267..a9f9c08 100644 --- a/backend/routers/app_dashboard.py +++ b/backend/routers/app_dashboard.py @@ -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) diff --git a/backend/tests/test_dashboard_widget_entitlements.py b/backend/tests/test_dashboard_widget_entitlements.py new file mode 100644 index 0000000..cd1efef --- /dev/null +++ b/backend/tests/test_dashboard_widget_entitlements.py @@ -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) diff --git a/backend/tests/test_widget_catalog.py b/backend/tests/test_widget_catalog.py index bf1e7d6..0ee34a8 100644 --- a/backend/tests/test_widget_catalog.py +++ b/backend/tests/test_widget_catalog.py @@ -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 diff --git a/backend/version.py b/backend/version.py index 6dca031..8f5f3e9 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 = [ diff --git a/backend/widget_catalog.py b/backend/widget_catalog.py index bf7cd33..af755d9 100644 --- a/backend/widget_catalog.py +++ b/backend/widget_catalog.py @@ -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), - } diff --git a/frontend/src/pages/DashboardLabPage.jsx b/frontend/src/pages/DashboardLabPage.jsx index 555f6f4..33628db 100644 --- a/frontend/src/pages/DashboardLabPage.jsx +++ b/frontend/src/pages/DashboardLabPage.jsx @@ -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 &&
{err}
} {msg &&{msg}
}