From 597486bef10e9e1ea6eda838a07f4ad50b5fc8ee Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 14 May 2026 07:47:27 +0200 Subject: [PATCH] feat(dashboard): add GET /api/dashboard/kpis endpoint and integrate into frontend - Implemented a new API endpoint for retrieving dashboard KPIs, providing a consolidated overview of drafts, personal exercises, and year-to-date completed units. - Updated the Dashboard component to utilize the new endpoint, enhancing data retrieval efficiency and user experience. - Added a helper function in the exercises router for programmatic access to exercise listings. - Updated versioning and changelog to reflect the addition of the dashboard feature. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 5 +- backend/main.py | 3 +- backend/routers/dashboard.py | 60 +++++++++++++++++++ backend/routers/exercises.py | 52 ++++++++++++++++ backend/tests/test_dashboard_kpis.py | 21 +++++++ backend/version.py | 11 +++- frontend/src/pages/Dashboard.jsx | 38 ++++-------- frontend/src/utils/api.js | 6 ++ tests/dev-smoke-test.spec.js | 11 ++-- 9 files changed, 171 insertions(+), 36 deletions(-) create mode 100644 backend/routers/dashboard.py create mode 100644 backend/tests/test_dashboard_kpis.py diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 984c0eb..5bc6f55 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) | | exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar | | training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id | +| dashboard | `GET /api/dashboard/kpis` | ja | `get_tenant_context` | wie `GET /api/exercises` + `GET /api/training-units` | Aggregat für Dashboard-Kurzüberblick (ein Roundtrip) | | training_modules | `/api/training-modules*` | ja | `get_tenant_context` | ja | Bibliotheks-Module wie Vorlagen/Rahmen; POST Default `club_id` bei `visibility=club` | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` | @@ -37,13 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-12 — Trainingsmodule (`/api/training-modules*`); Governance wie Planungsbibliothek. +Letzte Änderung: 2026-05-13 — `GET /api/dashboard/kpis` (Kurzüberblick-Aggregat). --- ### Changelog (Fortführung) -- **2026-05-12:** `training_modules` Router dokumentiert. +- **2026-05-13:** Dashboard-KPI-Endpunkt dokumentiert. - **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`. - **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`. diff --git a/backend/main.py b/backend/main.py index 108cfe0..4fa97c7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -193,7 +193,7 @@ def read_root(): return out # Register routers -from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports +from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports app.include_router(auth.router) app.include_router(profiles.router) @@ -209,6 +209,7 @@ app.include_router(media_assets.admin_rights_router) app.include_router(media_assets.admin_legal_hold_router) app.include_router(skills.router) app.include_router(training_planning.router) +app.include_router(dashboard.router) app.include_router(training_modules.router) app.include_router(training_framework_programs.router) app.include_router(catalogs.router) diff --git a/backend/routers/dashboard.py b/backend/routers/dashboard.py new file mode 100644 index 0000000..fe217f9 --- /dev/null +++ b/backend/routers/dashboard.py @@ -0,0 +1,60 @@ +""" +Dashboard: zusammengefasste Kennzahlen (ein Roundtrip statt mehrerer Listen). +""" +from __future__ import annotations + +from datetime import date + +from fastapi import APIRouter, Depends + +from tenant_context import TenantContext, get_tenant_context +from routers.exercises import list_exercises_like_get +from routers.training_planning import list_training_units + +router = APIRouter(prefix="/api", tags=["dashboard"]) + + +@router.get("/dashboard/kpis") +def get_dashboard_kpis(tenant: TenantContext = Depends(get_tenant_context)): + """ + Kurzüberblick-KPIs wie bisher drei parallele Client-Aufrufe: + listExercises (Entwürfe), listExercises (meine), listTrainingUnits (completed im Kalenderjahr). + """ + year = date.today().year + year_start = f"{year}-01-01" + year_end = f"{year}-12-31" + + draft_list = list_exercises_like_get( + tenant, created_by_me=True, status="draft", limit=100 + ) + mine_list = list_exercises_like_get( + tenant, created_by_me=True, status=None, limit=100 + ) + ytd_completed = list_training_units( + group_id=None, + club_id=None, + start_date=year_start, + end_date=year_end, + status="completed", + assigned_to_me=True, + debrief_pending=False, + sort="desc", + limit=250, + tenant=tenant, + ) + + draft_preview = [ + {"id": int(ex["id"]), "title": ex.get("title") or f"Übung #{ex['id']}"} + for ex in draft_list[:8] + ] + + return { + "year": year, + "draft_count": len(draft_list), + "draft_capped": len(draft_list) >= 100, + "draft_preview": draft_preview, + "mine_count": len(mine_list), + "mine_capped": len(mine_list) >= 100, + "ytd_completed_count": len(ytd_completed), + "ytd_capped": len(ytd_completed) >= 250, + } diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 1cdae79..3a8d3ab 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2076,6 +2076,58 @@ def list_exercises( return out +def list_exercises_like_get( + tenant: TenantContext, + *, + created_by_me: bool, + status: Optional[str], + limit: int, +) -> List[Dict[str, Any]]: + """ + Programmatischer Aufruf mit gleicher Semantik wie GET /api/exercises + (ohne FastAPI-Query-Default-Objekte an list_exercises zu übergeben). + """ + return list_exercises( + focus_area_ids=[], + focus_area=None, + visibility_any=[], + visibility=None, + status_any=[], + status=status, + skill_ids=[], + skill_id=None, + style_direction_ids=[], + style_direction_id=None, + training_type_ids=[], + training_type_id=None, + target_group_ids=[], + target_group_id=None, + skill_min_level=None, + skill_max_level=None, + search=None, + ai_search=None, + limit=limit, + offset=0, + include_variants=False, + visibility_exclude_any=[], + status_exclude_any=[], + exclude_without_focus=False, + focus_only_without_focus_areas=False, + focus_area_must_include_ids=[], + focus_area_must_exclude_ids=[], + style_direction_must_include_ids=[], + style_direction_must_exclude_ids=[], + training_type_must_include_ids=[], + training_type_must_exclude_ids=[], + target_group_must_include_ids=[], + target_group_must_exclude_ids=[], + include_archived=False, + created_by_me=created_by_me, + exercise_kind_any=[], + tenant=tenant, + ) + + @router.get("/exercises/{exercise_id}") def get_exercise( exercise_id: int, diff --git a/backend/tests/test_dashboard_kpis.py b/backend/tests/test_dashboard_kpis.py new file mode 100644 index 0000000..8847f01 --- /dev/null +++ b/backend/tests/test_dashboard_kpis.py @@ -0,0 +1,21 @@ +"""GET /api/dashboard/kpis: Auth (kein DB nötig).""" +from __future__ import annotations + +import os + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +def test_dashboard_kpis_unauthenticated_401(client: TestClient) -> None: + r = client.get("/api/dashboard/kpis") + assert r.status_code == 401 diff --git a/backend/version.py b/backend/version.py index b011ef4..5ef1004 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.110" +APP_VERSION = "0.8.111" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260512057" @@ -25,6 +25,7 @@ MODULE_VERSIONS = { "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run + "dashboard": "1.0.0", # GET /api/dashboard/kpis — Aggregat Entwürfe / meine Übungen / YTD completed "training_modules": "1.0.0", "import_wiki": "1.0.0", "admin": "1.0.0", @@ -35,6 +36,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.111", + "date": "2026-05-13", + "changes": [ + "GET /api/dashboard/kpis: Kurzüberblick (meine Entwürfe, meine Übungen, abgeschlossene Einheiten Kalenderjahr) in einem Aufruf; Dashboard-UI nutzt den Endpunkt.", + "Hilfsfunktion list_exercises_like_get in exercises-Router für programmatische Listen ohne Query-Defaults.", + ], + }, { "version": "0.8.110", "date": "2026-05-12", diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 266f9dc..1b38f29 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -93,35 +93,17 @@ function Dashboard() { ;(async () => { setPhase0Err(null) try { - const year = new Date().getFullYear() - const yearStart = `${year}-01-01` - const yearEnd = `${year}-12-31` - const [draftList, mineList, ytdCompleted] = await Promise.all([ - api.listExercises({ created_by_me: true, status: 'draft', limit: 100 }), - api.listExercises({ created_by_me: true, limit: 100 }), - api.listTrainingUnits({ - assigned_to_me: true, - status: 'completed', - start_date: yearStart, - end_date: yearEnd, - limit: 250, - sort: 'desc', - }), - ]) - if (!cancelled) { - const drafts = Array.isArray(draftList) ? draftList : [] + const data = await api.getDashboardKpis() + if (!cancelled && data && typeof data === 'object') { setPhase0Stats({ - year, - draftCount: drafts.length, - draftCapped: drafts.length >= 100, - draftPreview: drafts.slice(0, 8).map((ex) => ({ - id: ex.id, - title: ex.title || `Übung #${ex.id}`, - })), - mineCount: Array.isArray(mineList) ? mineList.length : 0, - mineCapped: Array.isArray(mineList) && mineList.length >= 100, - ytdCompletedCount: Array.isArray(ytdCompleted) ? ytdCompleted.length : 0, - ytdCapped: Array.isArray(ytdCompleted) && ytdCompleted.length >= 250, + year: data.year, + draftCount: data.draft_count, + draftCapped: Boolean(data.draft_capped), + draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [], + mineCount: data.mine_count ?? 0, + mineCapped: Boolean(data.mine_capped), + ytdCompletedCount: data.ytd_completed_count ?? 0, + ytdCapped: Boolean(data.ytd_capped), }) } } catch (e) { diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index edb1596..b58f622 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -1352,6 +1352,11 @@ export async function listTrainingUnits(filters = {}) { return request(`/api/training-units${qs ? `?${qs}` : ''}`) } +/** Dashboard Kurzüberblick: Entwürfe / meine Übungen / YTD abgeschlossene Einheiten (ein Roundtrip). */ +export async function getDashboardKpis() { + return request('/api/dashboard/kpis') +} + /** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */ export async function getTrainingExerciseClubVisibilityQueue(filters = {}) { const q = new URLSearchParams() @@ -1601,6 +1606,7 @@ export const api = { // Training Planning listTrainingUnits, + getDashboardKpis, getTrainingExerciseClubVisibilityQueue, getTrainingUnit, createTrainingUnit, diff --git a/tests/dev-smoke-test.spec.js b/tests/dev-smoke-test.spec.js index 6f3ec83..75eaae3 100644 --- a/tests/dev-smoke-test.spec.js +++ b/tests/dev-smoke-test.spec.js @@ -144,14 +144,15 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => { }); /** - * Refaktor Phase 1 (Dashboard): kein zweites GET /api/profiles/me; genau drei GET /api/training-units. + * Refaktor Phase 2 (Dashboard): Kurzüberblick per GET /api/dashboard/kpis; genau zwei GET /api/training-units (Übersicht). * Production-ähnlicher Build empfohlen (kein React StrictMode-Doppel-Mount im lokalen Vite-Dev). */ -test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async ({ page }) => { +test('8. Dashboard API-Budget nach Reload (profiles/me, training-units, dashboard/kpis)', async ({ page }) => { await login(page); let profilesMe = 0; let trainingUnits = 0; + let dashboardKpis = 0; const onRequest = (request) => { if (request.method() !== 'GET') return; @@ -163,6 +164,7 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async } if (pathname === '/api/profiles/me') profilesMe += 1; if (pathname === '/api/training-units') trainingUnits += 1; + if (pathname === '/api/dashboard/kpis') dashboardKpis += 1; }; page.on('request', onRequest); @@ -180,12 +182,13 @@ test('8. Dashboard API-Budget nach Reload (profiles/me, training-units)', async }); expect(profilesMe).toBe(1); - expect(trainingUnits).toBe(3); + expect(trainingUnits).toBe(2); + expect(dashboardKpis).toBe(1); } finally { page.off('request', onRequest); } - console.log('✓ Dashboard API-Budget: 1× profiles/me, 3× training-units'); + console.log('✓ Dashboard API-Budget: 1× profiles/me, 2× training-units, 1× dashboard/kpis'); }); test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {