Bug Fixing Kombi-Übungen - Performance Update 1 (Phase 0-2) #33

Merged
Lars merged 18 commits from develop into main 2026-05-14 09:09:55 +02:00
7 changed files with 161 additions and 18 deletions
Showing only changes of commit 789b640ad0 - Show all commits

View File

@ -9,6 +9,7 @@ import json
import logging import logging
import os import os
import re import re
from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
from urllib.parse import quote from urllib.parse import quote
@ -1653,6 +1654,20 @@ def bulk_patch_exercises_metadata(
} }
def _parse_cursor_updated_at_list(raw: Optional[str]) -> datetime:
s = (raw or "").strip()
if not s:
raise HTTPException(status_code=400, detail="cursor_updated_at leer")
if s.endswith("Z"):
s = s[:-1] + "+00:00"
try:
return datetime.fromisoformat(s)
except ValueError:
raise HTTPException(
status_code=400, detail="cursor_updated_at ungültig (ISO-8601 erwartet)"
)
@router.get("/exercises") @router.get("/exercises")
def list_exercises( def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"), focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),
@ -1678,6 +1693,15 @@ def list_exercises(
), ),
limit: int = Query(default=50, ge=1, le=100), limit: int = Query(default=50, ge=1, le=100),
offset: int = Query(default=0, ge=0), offset: int = Query(default=0, ge=0),
cursor_updated_at: Optional[str] = Query(
default=None,
description="Keyset: ISO-8601 von updated_at der letzten Zeile; zusammen mit cursor_id (offset dann 0)",
),
cursor_id: Optional[int] = Query(
default=None,
ge=1,
description="Keyset: id der letzten Zeile (Tiebreak bei gleichem updated_at); mit cursor_updated_at",
),
include_variants: bool = Query( include_variants: bool = Query(
default=False, default=False,
description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI",
@ -1746,9 +1770,26 @@ def list_exercises(
Liste aller Übungen mit Filtern. Liste aller Übungen mit Filtern.
Lightweight Response (ohne M:N Details, nur IDs und Namen). Lightweight Response (ohne M:N Details, nur IDs und Namen).
Optional include_variants für Variantenauswahl in der Trainingsplanung. Optional include_variants für Variantenauswahl in der Trainingsplanung.
Keyset: cursor_updated_at + cursor_id ersetzt große OFFSET-Werte (Sortierung: updated_at DESC, id DESC).
""" """
profile_id = tenant.profile_id profile_id = tenant.profile_id
c_ts_raw = (cursor_updated_at or "").strip() or None
use_keyset = c_ts_raw is not None and cursor_id is not None
if (c_ts_raw is not None) != (cursor_id is not None):
raise HTTPException(
status_code=400,
detail="cursor_updated_at und cursor_id müssen zusammen gesetzt werden",
)
if use_keyset and offset != 0:
raise HTTPException(
status_code=400,
detail="Keyset-Pagination: offset nicht kombinieren (nur cursor_* oder nur offset)",
)
cursor_ts_val: Optional[datetime] = None
if use_keyset:
cursor_ts_val = _parse_cursor_updated_at_list(c_ts_raw)
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -1981,6 +2022,12 @@ def list_exercises(
where.append("e.search_vector @@ plainto_tsquery('german', %s)") where.append("e.search_vector @@ plainto_tsquery('german', %s)")
params.append(qtext) params.append(qtext)
if cursor_ts_val is not None and cursor_id is not None:
where.append(
"(e.updated_at < %s OR (e.updated_at = %s AND e.id < %s))"
)
params.extend([cursor_ts_val, cursor_ts_val, cursor_id])
variants_sql = "" variants_sql = ""
if include_variants: if include_variants:
variants_sql = """, variants_sql = """,
@ -2046,10 +2093,10 @@ def list_exercises(
LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id LEFT JOIN clubs c ON e.club_id = c.id
WHERE {' AND '.join(where)} WHERE {' AND '.join(where)}
ORDER BY e.updated_at DESC ORDER BY e.updated_at DESC, e.id DESC
LIMIT %s OFFSET %s LIMIT %s OFFSET %s
""" """
params.extend([limit, offset]) params.extend([limit, 0 if use_keyset else offset])
cur.execute(query, params) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()

View File

@ -0,0 +1,82 @@
"""GET /api/exercises: Keyset-Parameter-Validierung (ohne DB-Zwang)."""
from __future__ import annotations
import os
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides() -> None:
yield
app.dependency_overrides.pop(require_auth, None)
app.dependency_overrides.pop(get_tenant_context, None)
def test_list_exercises_keyset_incomplete_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={"cursor_id": "42"},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "cursor_updated_at" in r.json().get("detail", "").lower()
def test_list_exercises_keyset_with_offset_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={
"cursor_id": "1",
"cursor_updated_at": "2026-01-01T12:00:00.000Z",
"offset": "10",
},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400
assert "offset" in r.json().get("detail", "").lower()
def test_list_exercises_keyset_bad_timestamp_returns_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get(
"/api/exercises",
params={"cursor_id": "1", "cursor_updated_at": "not-a-date"},
headers={"X-Auth-Token": "test"},
)
assert r.status_code == 400

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.114" APP_VERSION = "0.8.115"
BUILD_DATE = "2026-05-12" BUILD_DATE = "2026-05-12"
DB_SCHEMA_VERSION = "20260514060" DB_SCHEMA_VERSION = "20260514060"
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.27.4", # Migration 060: Listen-Skalierung (Partial + Junction is_primary) "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
"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
@ -36,6 +36,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.115",
"date": "2026-05-14",
"changes": [
"GET /api/exercises: optionale Keyset-Pagination (cursor_updated_at ISO-8601 + cursor_id), stabile Sortierung updated_at DESC, id DESC; „Mehr laden“ in Übungsliste und Picker nutzt Keyset statt OFFSET.",
],
},
{ {
"version": "0.8.114", "version": "0.8.114",
"date": "2026-05-14", "date": "2026-05-14",

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover # Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-14 **Stand:** 2026-05-14
**App-Version / DB-Schema:** App **0.8.114**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`) **App-Version / DB-Schema:** App **0.8.115**, DB-Schema **`20260514060`** (`backend/version.py`: `APP_VERSION`, `DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**. Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -76,7 +76,7 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**. - **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`. - **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.114**) ### Trainingsmodule, Kombinationsübungen und Coach (Stand **0.8.115**)
- **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **§10.6** Produkt-Backlog, **Anhang A** Abgleich). - **Fachspez & Drift-Schutz:** `.claude/docs/functional/Shinkan Trainingsmodule Kombinationsuebungen Spezifikation V2.md` (**§10.2.1** IDs, **§10.4** Coaching-Stufen, **§10.6** Produkt-Backlog, **Anhang A** Abgleich).
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung). - **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4ag** — u.a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).

View File

@ -64,7 +64,7 @@
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 | | Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
| Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 | | Optional: erste Keyset-Pagination für eine Liste mit bekanntem Sort-Key | B3 |
**Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index). **Teil erledigt (2026-05-14):** Migration **058** (`exercises`: globale `updated_at`-Sortierung / `created_by`+`updated_at`), **059** (`training_units`: Kalenderliste ohne Blueprint), **060** (`exercises`: Partial-Indizes `official`/`club` inkl. Archiv-Filter; Junction `is_primary` für List-Subqueries); **Keyset** für `GET /api/exercises` (`cursor_updated_at` + `cursor_id`, UI „Mehr laden“ in Liste + Picker). Rest: `EXPLAIN` unter Produktionsvolumen, Fähigkeits-Level-Filter nur bei Bedarf (ggf. Ausdrucks-Index).
**Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten. **Abnahme:** p95 der optimierten Routen **verbessert** ggü. Phase 0 oder dokumentierte Obergrenze eingehalten.

View File

@ -53,7 +53,6 @@ export default function ExercisePickerModal({
const [list, setList] = useState([]) const [list, setList] = useState([])
const [loading, setLoading] = useState(false) const [loading, setLoading] = useState(false)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [multiPicked, setMultiPicked] = useState([]) const [multiPicked, setMultiPicked] = useState([])
const [quickOpen, setQuickOpen] = useState(false) const [quickOpen, setQuickOpen] = useState(false)
@ -118,7 +117,6 @@ export default function ExercisePickerModal({
setFilters({ ...INITIAL_FILTERS }) setFilters({ ...INITIAL_FILTERS })
setFilterOpen(false) setFilterOpen(false)
setList([]) setList([])
setOffset(0)
setHasMore(false) setHasMore(false)
setMultiPicked([]) setMultiPicked([])
setQuickOpen(false) setQuickOpen(false)
@ -227,7 +225,6 @@ export default function ExercisePickerModal({
const reload = useCallback(async () => { const reload = useCallback(async () => {
if (!open || !catalogsReady) return if (!open || !catalogsReady) return
setLoading(true) setLoading(true)
setOffset(0)
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
...queryBase, ...queryBase,
@ -238,7 +235,6 @@ export default function ExercisePickerModal({
}) })
setList(Array.isArray(batch) ? batch : []) setList(Array.isArray(batch) ? batch : [])
setHasMore(batch?.length === PAGE_SIZE) setHasMore(batch?.length === PAGE_SIZE)
setOffset(batch?.length ?? 0)
} catch (e) { } catch (e) {
console.error(e) console.error(e)
alert(e.message || 'Laden fehlgeschlagen') alert(e.message || 'Laden fehlgeschlagen')
@ -255,6 +251,8 @@ export default function ExercisePickerModal({
const loadMore = async () => { const loadMore = async () => {
if (!hasMore || loadingMore || loading) return if (!hasMore || loadingMore || loading) return
const last = list[list.length - 1]
if (!last?.id || last.updated_at == null) return
setLoadingMore(true) setLoadingMore(true)
try { try {
const batch = await api.listExercises({ const batch = await api.listExercises({
@ -262,11 +260,14 @@ export default function ExercisePickerModal({
include_archived: true, include_archived: true,
include_variants: true, include_variants: true,
limit: PAGE_SIZE, limit: PAGE_SIZE,
offset, cursor_updated_at:
typeof last.updated_at === 'string'
? last.updated_at
: new Date(last.updated_at).toISOString(),
cursor_id: last.id,
}) })
setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])]) setList((prev) => [...prev, ...(Array.isArray(batch) ? batch : [])])
setHasMore(batch?.length === PAGE_SIZE) setHasMore(batch?.length === PAGE_SIZE)
setOffset((o) => o + (batch?.length ?? 0))
} catch (e) { } catch (e) {
console.error(e) console.error(e)
alert(e.message || 'Mehr laden fehlgeschlagen') alert(e.message || 'Mehr laden fehlgeschlagen')

View File

@ -177,7 +177,6 @@ function ExercisesListPage() {
const [catalogsReady, setCatalogsReady] = useState(false) const [catalogsReady, setCatalogsReady] = useState(false)
const [listFetching, setListFetching] = useState(false) const [listFetching, setListFetching] = useState(false)
const [loadingMore, setLoadingMore] = useState(false) const [loadingMore, setLoadingMore] = useState(false)
const [offset, setOffset] = useState(0)
const [hasMore, setHasMore] = useState(false) const [hasMore, setHasMore] = useState(false)
const [searchInput, setSearchInput] = useState('') const [searchInput, setSearchInput] = useState('')
const [aiSearchInput, setAiSearchInput] = useState('') const [aiSearchInput, setAiSearchInput] = useState('')
@ -604,13 +603,11 @@ function ExercisesListPage() {
let cancelled = false let cancelled = false
const run = async () => { const run = async () => {
setListFetching(true) setListFetching(true)
setOffset(0)
try { try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 }) const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
if (cancelled) return if (cancelled) return
setExercises(batch) setExercises(batch)
setHasMore(batch.length === PAGE_SIZE) setHasMore(batch.length === PAGE_SIZE)
setOffset(batch.length)
} catch (err) { } catch (err) {
if (!cancelled) { if (!cancelled) {
console.error('Failed to load data:', err) console.error('Failed to load data:', err)
@ -628,12 +625,21 @@ function ExercisesListPage() {
const loadMore = async () => { const loadMore = async () => {
if (loadingMore || !hasMore) return if (loadingMore || !hasMore) return
const last = exercises[exercises.length - 1]
if (!last?.id || last.updated_at == null) return
setLoadingMore(true) setLoadingMore(true)
try { try {
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset }) const batch = await api.listExercises({
...queryBase,
limit: PAGE_SIZE,
cursor_updated_at:
typeof last.updated_at === 'string'
? last.updated_at
: new Date(last.updated_at).toISOString(),
cursor_id: last.id,
})
setExercises((prev) => [...prev, ...batch]) setExercises((prev) => [...prev, ...batch])
setHasMore(batch.length === PAGE_SIZE) setHasMore(batch.length === PAGE_SIZE)
setOffset((o) => o + batch.length)
} catch (err) { } catch (err) {
alert('Fehler: ' + err.message) alert('Fehler: ' + err.message)
} finally { } finally {