feat(dashboard): add GET /api/dashboard/kpis endpoint and integrate into frontend
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 1m32s

- 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.
This commit is contained in:
Lars 2026-05-14 07:47:27 +02:00
parent ebad8025f4
commit 597486bef1
9 changed files with 171 additions and 36 deletions

View File

@ -15,6 +15,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 AC.
**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`.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 }) => {