chore(version): update version and changelog for release 0.8.115
All checks were successful
Deploy Development / deploy (push) Successful in 39s
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 1m28s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
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 1m28s
- Bumped APP_VERSION to 0.8.115 and updated the changelog to reflect changes, including the introduction of keyset pagination for the GET /api/exercises endpoint. - Enhanced the exercises router to support cursor-based pagination using cursor_updated_at and cursor_id, improving performance and user experience. - Updated frontend components to utilize the new pagination method, removing offset-based loading logic.
This commit is contained in:
parent
14cf8a1a53
commit
789b640ad0
|
|
@ -9,6 +9,7 @@ import json
|
|||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Literal, Optional, Tuple
|
||||
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")
|
||||
def list_exercises(
|
||||
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),
|
||||
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 (Tie‑break bei gleichem updated_at); mit cursor_updated_at",
|
||||
),
|
||||
include_variants: bool = Query(
|
||||
default=False,
|
||||
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.
|
||||
Lightweight Response (ohne M:N Details, nur IDs und Namen).
|
||||
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
|
||||
|
||||
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:
|
||||
cur = get_cursor(conn)
|
||||
|
||||
|
|
@ -1981,6 +2022,12 @@ def list_exercises(
|
|||
where.append("e.search_vector @@ plainto_tsquery('german', %s)")
|
||||
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 = ""
|
||||
if include_variants:
|
||||
variants_sql = """,
|
||||
|
|
@ -2046,10 +2093,10 @@ def list_exercises(
|
|||
LEFT JOIN profiles p ON e.created_by = p.id
|
||||
LEFT JOIN clubs c ON e.club_id = c.id
|
||||
WHERE {' AND '.join(where)}
|
||||
ORDER BY e.updated_at DESC
|
||||
ORDER BY e.updated_at DESC, e.id DESC
|
||||
LIMIT %s OFFSET %s
|
||||
"""
|
||||
params.extend([limit, offset])
|
||||
params.extend([limit, 0 if use_keyset else offset])
|
||||
|
||||
cur.execute(query, params)
|
||||
rows = cur.fetchall()
|
||||
|
|
|
|||
82
backend/tests/test_exercises_list_keyset.py
Normal file
82
backend/tests/test_exercises_list_keyset.py
Normal 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
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.114"
|
||||
APP_VERSION = "0.8.115"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514060"
|
||||
|
||||
|
|
@ -21,7 +21,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "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_programs": "0.1.0",
|
||||
"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 = [
|
||||
{
|
||||
"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",
|
||||
"date": "2026-05-14",
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo – Entwicklungsstand & Handover
|
||||
|
||||
**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**.
|
||||
|
||||
|
|
@ -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`**.
|
||||
- **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).
|
||||
- **Umsetzungsplan:** `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md` (Phase **2** / **4** teilweise; Pakete **4a–g** — u. a. **4e** Archetyp-Admin, **4f** Massen-Vorbelegung, **4g** Backend-Validierung).
|
||||
|
|
|
|||
|
|
@ -64,7 +64,7 @@
|
|||
| Summary-API finalisieren/erweitern falls in P1 nur Teilbereich | B1 |
|
||||
| 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.
|
||||
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ export default function ExercisePickerModal({
|
|||
const [list, setList] = useState([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [multiPicked, setMultiPicked] = useState([])
|
||||
const [quickOpen, setQuickOpen] = useState(false)
|
||||
|
|
@ -118,7 +117,6 @@ export default function ExercisePickerModal({
|
|||
setFilters({ ...INITIAL_FILTERS })
|
||||
setFilterOpen(false)
|
||||
setList([])
|
||||
setOffset(0)
|
||||
setHasMore(false)
|
||||
setMultiPicked([])
|
||||
setQuickOpen(false)
|
||||
|
|
@ -227,7 +225,6 @@ export default function ExercisePickerModal({
|
|||
const reload = useCallback(async () => {
|
||||
if (!open || !catalogsReady) return
|
||||
setLoading(true)
|
||||
setOffset(0)
|
||||
try {
|
||||
const batch = await api.listExercises({
|
||||
...queryBase,
|
||||
|
|
@ -238,7 +235,6 @@ export default function ExercisePickerModal({
|
|||
})
|
||||
setList(Array.isArray(batch) ? batch : [])
|
||||
setHasMore(batch?.length === PAGE_SIZE)
|
||||
setOffset(batch?.length ?? 0)
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert(e.message || 'Laden fehlgeschlagen')
|
||||
|
|
@ -255,6 +251,8 @@ export default function ExercisePickerModal({
|
|||
|
||||
const loadMore = async () => {
|
||||
if (!hasMore || loadingMore || loading) return
|
||||
const last = list[list.length - 1]
|
||||
if (!last?.id || last.updated_at == null) return
|
||||
setLoadingMore(true)
|
||||
try {
|
||||
const batch = await api.listExercises({
|
||||
|
|
@ -262,11 +260,14 @@ export default function ExercisePickerModal({
|
|||
include_archived: true,
|
||||
include_variants: true,
|
||||
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 : [])])
|
||||
setHasMore(batch?.length === PAGE_SIZE)
|
||||
setOffset((o) => o + (batch?.length ?? 0))
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
alert(e.message || 'Mehr laden fehlgeschlagen')
|
||||
|
|
|
|||
|
|
@ -177,7 +177,6 @@ function ExercisesListPage() {
|
|||
const [catalogsReady, setCatalogsReady] = useState(false)
|
||||
const [listFetching, setListFetching] = useState(false)
|
||||
const [loadingMore, setLoadingMore] = useState(false)
|
||||
const [offset, setOffset] = useState(0)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [searchInput, setSearchInput] = useState('')
|
||||
const [aiSearchInput, setAiSearchInput] = useState('')
|
||||
|
|
@ -604,13 +603,11 @@ function ExercisesListPage() {
|
|||
let cancelled = false
|
||||
const run = async () => {
|
||||
setListFetching(true)
|
||||
setOffset(0)
|
||||
try {
|
||||
const batch = await api.listExercises({ ...queryBase, limit: PAGE_SIZE, offset: 0 })
|
||||
if (cancelled) return
|
||||
setExercises(batch)
|
||||
setHasMore(batch.length === PAGE_SIZE)
|
||||
setOffset(batch.length)
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
console.error('Failed to load data:', err)
|
||||
|
|
@ -628,12 +625,21 @@ function ExercisesListPage() {
|
|||
|
||||
const loadMore = async () => {
|
||||
if (loadingMore || !hasMore) return
|
||||
const last = exercises[exercises.length - 1]
|
||||
if (!last?.id || last.updated_at == null) return
|
||||
setLoadingMore(true)
|
||||
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])
|
||||
setHasMore(batch.length === PAGE_SIZE)
|
||||
setOffset((o) => o + batch.length)
|
||||
} catch (err) {
|
||||
alert('Fehler: ' + err.message)
|
||||
} finally {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user