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
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:
parent
ebad8025f4
commit
597486bef1
|
|
@ -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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
60
backend/routers/dashboard.py
Normal file
60
backend/routers/dashboard.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
21
backend/tests/test_dashboard_kpis.py
Normal file
21
backend/tests/test_dashboard_kpis.py
Normal 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
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 }) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user