Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2) #33
|
|
@ -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) |
|
| 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 |
|
| 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 |
|
| 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_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 |
|
| 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` |
|
| 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.
|
**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)
|
### 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:** 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`.
|
- **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
|
return out
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.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(media_assets.admin_legal_hold_router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
|
app.include_router(dashboard.router)
|
||||||
app.include_router(training_modules.router)
|
app.include_router(training_modules.router)
|
||||||
app.include_router(training_framework_programs.router)
|
app.include_router(training_framework_programs.router)
|
||||||
app.include_router(catalogs.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
|
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}")
|
@router.get("/exercises/{exercise_id}")
|
||||||
def get_exercise(
|
def get_exercise(
|
||||||
exercise_id: int,
|
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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.110"
|
APP_VERSION = "0.8.111"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260512057"
|
DB_SCHEMA_VERSION = "20260512057"
|
||||||
|
|
||||||
|
|
@ -25,6 +25,7 @@ MODULE_VERSIONS = {
|
||||||
"training_units": "0.2.0",
|
"training_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.9.3", # GET training-units/:id Sektions-Items: combination_slots + Kandidaten-Titel für Druck/Run
|
"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",
|
"training_modules": "1.0.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
|
|
@ -35,6 +36,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.110",
|
||||||
"date": "2026-05-12",
|
"date": "2026-05-12",
|
||||||
|
|
|
||||||
|
|
@ -93,35 +93,17 @@ function Dashboard() {
|
||||||
;(async () => {
|
;(async () => {
|
||||||
setPhase0Err(null)
|
setPhase0Err(null)
|
||||||
try {
|
try {
|
||||||
const year = new Date().getFullYear()
|
const data = await api.getDashboardKpis()
|
||||||
const yearStart = `${year}-01-01`
|
if (!cancelled && data && typeof data === 'object') {
|
||||||
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 : []
|
|
||||||
setPhase0Stats({
|
setPhase0Stats({
|
||||||
year,
|
year: data.year,
|
||||||
draftCount: drafts.length,
|
draftCount: data.draft_count,
|
||||||
draftCapped: drafts.length >= 100,
|
draftCapped: Boolean(data.draft_capped),
|
||||||
draftPreview: drafts.slice(0, 8).map((ex) => ({
|
draftPreview: Array.isArray(data.draft_preview) ? data.draft_preview : [],
|
||||||
id: ex.id,
|
mineCount: data.mine_count ?? 0,
|
||||||
title: ex.title || `Übung #${ex.id}`,
|
mineCapped: Boolean(data.mine_capped),
|
||||||
})),
|
ytdCompletedCount: data.ytd_completed_count ?? 0,
|
||||||
mineCount: Array.isArray(mineList) ? mineList.length : 0,
|
ytdCapped: Boolean(data.ytd_capped),
|
||||||
mineCapped: Array.isArray(mineList) && mineList.length >= 100,
|
|
||||||
ytdCompletedCount: Array.isArray(ytdCompleted) ? ytdCompleted.length : 0,
|
|
||||||
ytdCapped: Array.isArray(ytdCompleted) && ytdCompleted.length >= 250,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
|
|
|
||||||
|
|
@ -1352,6 +1352,11 @@ export async function listTrainingUnits(filters = {}) {
|
||||||
return request(`/api/training-units${qs ? `?${qs}` : ''}`)
|
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. */
|
/** Dashboard: Übungen in geplanten Einheiten, die für den Verein noch auf Sichtbarkeit „Verein“ gehören. */
|
||||||
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
|
export async function getTrainingExerciseClubVisibilityQueue(filters = {}) {
|
||||||
const q = new URLSearchParams()
|
const q = new URLSearchParams()
|
||||||
|
|
@ -1601,6 +1606,7 @@ export const api = {
|
||||||
|
|
||||||
// Training Planning
|
// Training Planning
|
||||||
listTrainingUnits,
|
listTrainingUnits,
|
||||||
|
getDashboardKpis,
|
||||||
getTrainingExerciseClubVisibilityQueue,
|
getTrainingExerciseClubVisibilityQueue,
|
||||||
getTrainingUnit,
|
getTrainingUnit,
|
||||||
createTrainingUnit,
|
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).
|
* 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);
|
await login(page);
|
||||||
|
|
||||||
let profilesMe = 0;
|
let profilesMe = 0;
|
||||||
let trainingUnits = 0;
|
let trainingUnits = 0;
|
||||||
|
let dashboardKpis = 0;
|
||||||
|
|
||||||
const onRequest = (request) => {
|
const onRequest = (request) => {
|
||||||
if (request.method() !== 'GET') return;
|
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/profiles/me') profilesMe += 1;
|
||||||
if (pathname === '/api/training-units') trainingUnits += 1;
|
if (pathname === '/api/training-units') trainingUnits += 1;
|
||||||
|
if (pathname === '/api/dashboard/kpis') dashboardKpis += 1;
|
||||||
};
|
};
|
||||||
|
|
||||||
page.on('request', onRequest);
|
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(profilesMe).toBe(1);
|
||||||
expect(trainingUnits).toBe(3);
|
expect(trainingUnits).toBe(2);
|
||||||
|
expect(dashboardKpis).toBe(1);
|
||||||
} finally {
|
} finally {
|
||||||
page.off('request', onRequest);
|
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 }) => {
|
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user