Enhance training unit sections handling and documentation for parallel training streams
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m9s
All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m9s
- Updated the backend to improve the fetching and insertion of training unit sections, including a new function for handling section items. - Added documentation notes regarding the unique constraint on `training_unit_sections` and the implications for parallel training streams. - Updated frontend components and utility functions to reflect changes in the training planning API and to prepare for future enhancements related to parallel streams.
This commit is contained in:
parent
e759076a6c
commit
220a16429c
|
|
@ -482,6 +482,8 @@ skill_level_definitions (
|
|||
|
||||
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
||||
|
||||
**Schema-Hinweis (2026-05):** Tabelle `training_unit_sections` hat **`UNIQUE (training_unit_id, order_index)`** (Migration 031). Damit sind **zwei gleichzeitige „Spuren“ mit jeweils eigener Sektion auf derselben `order_index`** nicht abbildbar — Voraussetzung für Parallele Streams ist eine **geplante Migrations-/Constraint-Anpassung** (partielle Uniques pro Phase/Stream); siehe Arbeitsdokument `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`. **Keine invasive Migration ohne explizite Freigabe.**
|
||||
|
||||
---
|
||||
|
||||
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,125 @@
|
|||
# Parallele Trainingsstreams — Ist-Analyse und risikoarmer Umsetzungsplan
|
||||
|
||||
**Status:** Stufe A (Analyse/Plan, ohne produktive Umsetzung in jener Session)
|
||||
**Stand:** 2026-05-14
|
||||
**Verbindliche fachliche Basis:** `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||
|
||||
Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis (`training_planning.py`, `training_framework_programs.py`, `training_unit_sections`/`items`, Frontend Planung/Run/Coach) und den empfohlenen Implementierungspfad.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zusammenfassung
|
||||
|
||||
- Plan-Inhalt pro Einheit ist heute **eine flache Liste** `training_unit_sections` mit **`UNIQUE (training_unit_id, order_index)`** (Migration 031) und `training_unit_section_items`; zentral: **`_fetch_sections`**, **`_replace_unit_sections`**, **`_hydrate_training_unit_payload`** in `backend/routers/training_planning.py`.
|
||||
- Parallele Phasen/Streams **passen** zu den Produktregeln (ein Kalendertermin, N Streams, je Miniplan), sind im Schema aber **nicht** abbildbar ohne Erweiterung und **ohne Auflösung** des globalen `order_index`-Modells.
|
||||
- **Empfehlung:** **Normalisierte** Tabellen `training_unit_phases`, `training_unit_parallel_streams`, erweiterte `training_unit_sections` mit FK auf Phase bzw. Stream, **partielle Unique-Indizes** statt `UNIQUE (training_unit_id, order_index)` für alle Sektionen.
|
||||
- **Blocker im Code:** u. a. `POST /api/training-units/{id}/apply-training-module` mit **`section_order_index` global pro Einheit** (`_resolve_training_unit_section_id`).
|
||||
- **Nicht persistiert an anderer Stelle:** Erste Fassung existierte nur als Chat-Antwort; dieses File ist die **kanonische** Arbeitskopie im Repo.
|
||||
|
||||
---
|
||||
|
||||
## 2. Ist-Analyse (kurz)
|
||||
|
||||
### Datenbank
|
||||
|
||||
- `training_unit_sections`: u. a. `training_unit_id`, `order_index`, `UNIQUE (training_unit_id, order_index)`.
|
||||
- `training_unit_section_items`: Übung/Notiz, `planning_method_profile` (Kombi), `source_training_module_id`.
|
||||
|
||||
### Backend (`training_planning.py`)
|
||||
|
||||
- `_replace_unit_sections`: DELETE aller Sektionen der Einheit + INSERT (vollständiger Ersetzungsbaum).
|
||||
- `_sections_clone_payload` + `_copy_blueprint_into_scheduled_unit`: tiefe Kopie für `from-framework-slot`.
|
||||
- `_flatten_exercises_from_sections`: flaches `exercises` am Unit-Payload.
|
||||
- `apply_training_module_to_training_unit`: Sektion per **`section_order_index`** global.
|
||||
|
||||
### Rahmen (`training_framework_programs.py`)
|
||||
|
||||
- Blueprint-`training_units` pro Slot; gleiche `_replace_unit_sections`-Semantik.
|
||||
|
||||
### Frontend
|
||||
|
||||
- Planung: `TrainingPlanningPageRoot.jsx`, `TrainingUnitSectionsEditor`, `buildSectionsPayload` / `normalizeUnitToForm`.
|
||||
- Run: `TrainingUnitRunPage.jsx` — Fortschritt `sessionStorage` Key `sj_training_run_checked_${unitId}`.
|
||||
- Coach: `TrainingCoachPage.jsx` — `flattenPlanTimeline` (linearer Ablauf).
|
||||
|
||||
### Tests
|
||||
|
||||
- Kaum Abdeckung für Plan-Inhalt; vorhanden u. a. `test_training_unit_assignments.py` (Merge Co-Trainer, ohne DB), `test_training_units_list_keyset.py` (Keyset-Validierung).
|
||||
|
||||
---
|
||||
|
||||
## 3. Technische Optionen und Empfehlung
|
||||
|
||||
| Option | Kurz |
|
||||
|--------|------|
|
||||
| A JSONB nur auf `training_units` | Niedriges DDL-Risiko, hohes Drift-/Wartungsrisiko — **nicht empfohlen** |
|
||||
| B Normalisiert Phasen/Streams | **Empfohlen** — eine Wahrheit, saubere Kopie, Rahmen kompatibel |
|
||||
| C Nur UI-Konvention ohne DB | Widerspricht Produkt — **abgelehnt** |
|
||||
|
||||
---
|
||||
|
||||
## 4. Migrations- und Kompatibilitätsstrategie
|
||||
|
||||
- Default **`whole_group`‑Phase** für alle bestehenden Einheiten; alle bisherigen Sektionen erhalten `phase_id`.
|
||||
- Unique-Regel: **pro Phase** bzw. **pro Stream** `order_index` eindeutig (partielle Unique-Indizes).
|
||||
- API optional: zusätzlich abgeleitetes flaches `sections` für Übergang — Entscheidung je nach Consumer (praktisch nur dieses Frontend).
|
||||
|
||||
---
|
||||
|
||||
## 5. API- / Frontend-Hotspots
|
||||
|
||||
- `GET`/`PUT` `/api/training-units/{id}`: verschachtelte `phases` / `streams` / `sections` / `items`.
|
||||
- `POST .../apply-training-module`: Kontext **Phase/Stream + Sektionsindex im Träger**.
|
||||
- Run/Coach: stream-spezifischer Fortschritt; `flattenPlanTimeline` phase-aware oder pro Stream.
|
||||
|
||||
---
|
||||
|
||||
## 6. Implementierungspakete (Überblick)
|
||||
|
||||
0. Spike DDL + Contract-Doku
|
||||
1. Schema + Migration + hydrate/replace
|
||||
2. PUT + Module + Clone (`from-framework-slot`)
|
||||
3. Planung UI
|
||||
4. Run + Coach
|
||||
5. Co-Trainer pro Stream
|
||||
6. MVP+ (Duplizieren, Verschieben, „nur meine Spur“)
|
||||
|
||||
---
|
||||
|
||||
## 7. Risiken
|
||||
|
||||
- Migration Unique-Constraint / bestehende Daten.
|
||||
- Regression Run/Coach / Dashboard-Joins (meist unkritisch, solange `training_unit_id` auf Sektionen bleibt).
|
||||
- Rahmen-Blueprints: gleiche Struktur wie Kalender-Einheiten anstreben (oder bewusst zweite Phase nur Kalender).
|
||||
|
||||
---
|
||||
|
||||
## 8. Offene Produkt-/Technikfragen
|
||||
|
||||
- Rahmen-Blueprint parallel im MVP oder erst nach Kalender-Einheit?
|
||||
- Semantik `exercises`-Flatlist bei Parallelität.
|
||||
- Merge-Regel `assistant_trainer_profile_ids` Kopf vs. Stream-Zuweisungen.
|
||||
|
||||
---
|
||||
|
||||
## 9. Verweise
|
||||
|
||||
- Fachkonzept: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||
- Technische Spec (Entwurf): `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||
- Domänenüberblick: `.claude/docs/functional/DOMAIN_MODEL.md` (Abschnitt Parallele Streams)
|
||||
- `./PARALLEL_TRAINING_STREAMS_PREREQ_PROMPT.md` — **Prompt** für Folgesession (Performance/Wartung/Vorbereitung)
|
||||
|
||||
---
|
||||
|
||||
## 10. Vorbereitende Arbeiten (Session 2026-05-13)
|
||||
|
||||
Ohne produktives Parallel-Feature, nur Risikoabbau und Transparenz:
|
||||
|
||||
- **`training_planning.py`:** Lesepfad `_fetch_sections` in SQL-Konstanten + `_fetch_section_items_for_section` / `_hydrate_section_item_combination_slots` strukturiert; `_replace_unit_sections` delegiert an `_insert_one_replacement_section`; `_hydrate_training_unit_payload` dokumentiert.
|
||||
- **Tests:** `tests/test_training_planning_sections_pure.py` (flatten, ohne DB); `tests/test_training_planning_sections_integration.py` (Roundtrip replace↔fetch bei `TRAINING_PLANNING_INTEGRATION=1`).
|
||||
- **Frontend:** Kurzkommentare an Planung (`TrainingPlanningPageRoot`, `buildSectionsPayload`), Run, Coach, `flattenPlanTimeline` — Anbindungspunkte für spätere Phase/Stream-Logik.
|
||||
- **DOMAIN_MODEL:** UNIQUE-Hinweis und „keine Migration ohne Freigabe“.
|
||||
|
||||
**Empfohlene nächste Schritte:** Pakete **0** (DDL/Contract festzurren) und **1** (Schema + Migration + hydrate/replace laut Plan Abschnitt 4–6) in einer dedizierten Feature-Session; danach Paket **2** (PUT/Module/Clone).
|
||||
|
||||
---
|
||||
|
|
@ -0,0 +1,41 @@
|
|||
# Prompt: Vorbereitungs- / Vorarbeit-Session (Performance & Wartung) für „Parallele Trainingsstreams“
|
||||
|
||||
**Kontext:** Du arbeitest in **Shinkan Jinkendo** (`c:\Dev\shinkan-jinkendo`). Das Feature **Parallele Trainingsstreams / Breakout** ist **inhaltlich** spezifiziert; eine **Ist-Analyse und ein risikoarmer Umsetzungsplan** liegen **persistiert** in:
|
||||
|
||||
- `.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md`
|
||||
- Fachlich: `.claude/docs/functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`
|
||||
- Technik-Entwurf: `.claude/docs/technical/PARALLEL_TRAINING_STREAMS_SPEC.md`
|
||||
|
||||
**Deine Rolle:** Du hast bereits **Refaktorierungs- und Wartungsaufgaben** mit Fokus **Performance, Lesbarkeit und Testbarkeit** durchgeführt. In **dieser** Session geht es **nicht** darum, das komplette Parallel-Feature zu bauen, sondern um **Vorarbeiten („Prerequisites“)**, die die geplante Komplexitätsauflösung **sicherer und billiger** machen.
|
||||
|
||||
## Ziele
|
||||
|
||||
1. **Lesepfad Planung vereinheitlichen:** `backend/routers/training_planning.py` ist zentral für `_fetch_sections`, `_replace_unit_sections`, `_hydrate_training_unit_payload`, Klonen, Blueprint-Kopie, `apply-training-module`. Prüfe, ob klar abgegrenzte Hilfsfunktionen (ohne Verhaltensänderung) die **nächste** große Änderung erleichtern — **keine** Feature-Logik für Phasen/Streams hinzufügen, nur Struktur/Tests/Docs wenn nötig.
|
||||
|
||||
2. **Test-Lücken schließen (minimal, hoher Nutzen):** Heute fehlen **DB/API-Tests** für kritische Pfade (`_replace_unit_sections` Roundtrip, `from-framework-slot` Struktur-Kopie, optional `apply-training-module`). Ergänze **kleine, deterministische** Tests (pytest mit DB, falls im Projekt üblich), ohne riesige Fixtures.
|
||||
|
||||
3. **Frontend-Schneidstellen markieren:** kurze Kommentare oder ein **Working-Doc-Update**, wo `TrainingPlanningPageRoot`, `buildSectionsPayload`, `TrainingUnitRunPage`, `TrainingCoachPage` + `trainingPlanUtils.flattenPlanTimeline` später angebunden werden — **kein** großes UI-Rewrite.
|
||||
|
||||
4. **Migrations-Sicherheit:** Dokumentiere in **einem Absatz** im `ANALYSIS`-Dokument oder hier, welche **Unique-Constraints** (`training_unit_sections`: `UNIQUE (training_unit_id, order_index)`) die Parallelität blockieren — **ohne** sie schon zu ändern, außer es ist Teil einer **explizit** freigegebenen ersten Migration.
|
||||
|
||||
5. **Performance nur berührensensible Stellen:** Einzelabruf `GET /api/training-units/{id}` wird mit mehr JOINs kommen. Falls du **jetzt** N+1 oder redundante Arbeit in `_fetch_sections` siehst und das **risikoarm** verbesserbar ist, nur mit **Messpunkt/Messvorstellung** (kein unnötiger Micro-Optimismus).
|
||||
|
||||
## Leitplanken
|
||||
|
||||
- **Stabilität vor Geschwindigkeit:** Keine Änderung, die bestehende Einheiten, Rahmen-Blueprints oder Run-Modus bricht.
|
||||
- **Keine pauschalen Refactors:** Nur Änderungen mit **klarem** Träger für das Parallel-Feature oder mit **Test-Regression-Schutz**.
|
||||
- **Regeln:** `.claude/rules/ARCHITECTURE.md`, `CODING_RULES.md`, Zugriffsschicht wo relevant.
|
||||
|
||||
## Erwartete Ausgabe
|
||||
|
||||
1. Kurze **Liste erledigter Vorarbeiten** (Dateien, was warum).
|
||||
2. **Empfohlene Reihenfolge** für die **nächste** Session, die Phasen/Streams **implementiert** (verweis auf `PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md` Pakete 0–2).
|
||||
3. Falls nichts Sinnvolles ohne Feature-Branch riskiert werden kann: **explizit** „keine Code-Änderung“, nur Risiko-Notiz.
|
||||
|
||||
## Optionaler Startbefehl
|
||||
|
||||
```
|
||||
Lies zuerst:
|
||||
.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md
|
||||
dann backend/routers/training_planning.py (Abschnitte um _fetch_sections, _replace_unit_sections).
|
||||
```
|
||||
|
|
@ -522,21 +522,15 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
|
|||
return i
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
cur.execute(
|
||||
"""
|
||||
# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ──────────────────
|
||||
# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich.
|
||||
_SECTION_ROWS_SQL = """
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
FROM training_unit_sections
|
||||
WHERE training_unit_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(unit_id,),
|
||||
)
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
cur.execute(
|
||||
"""
|
||||
"""
|
||||
_SECTION_ITEMS_ROWS_SQL = """
|
||||
SELECT tusi.*,
|
||||
e.title AS exercise_title,
|
||||
e.exercise_kind AS exercise_kind,
|
||||
|
|
@ -558,26 +552,43 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
|
||||
WHERE tusi.section_id = %s
|
||||
ORDER BY tusi.order_index
|
||||
""",
|
||||
(sec["id"],),
|
||||
)
|
||||
sec["items"] = [r2d(r) for r in cur.fetchall()]
|
||||
for it in sec["items"]:
|
||||
if it.get("item_type") != "exercise":
|
||||
continue
|
||||
cmp_raw = it.get("catalog_method_profile")
|
||||
if not isinstance(cmp_raw, dict):
|
||||
it["catalog_method_profile"] = {}
|
||||
else:
|
||||
it["catalog_method_profile"] = dict(cmp_raw)
|
||||
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
||||
if ek == "combination" and it.get("exercise_id"):
|
||||
try:
|
||||
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
it["combination_slots"] = []
|
||||
else:
|
||||
it["combination_slots"] = []
|
||||
"""
|
||||
|
||||
|
||||
def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None:
|
||||
"""Setzt `combination_slots` für Kombi‑Übungen; sonst leere Liste."""
|
||||
if it.get("item_type") != "exercise":
|
||||
return
|
||||
cmp_raw = it.get("catalog_method_profile")
|
||||
if not isinstance(cmp_raw, dict):
|
||||
it["catalog_method_profile"] = {}
|
||||
else:
|
||||
it["catalog_method_profile"] = dict(cmp_raw)
|
||||
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
||||
if ek == "combination" and it.get("exercise_id"):
|
||||
try:
|
||||
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
||||
except (TypeError, ValueError):
|
||||
it["combination_slots"] = []
|
||||
else:
|
||||
it["combination_slots"] = []
|
||||
|
||||
|
||||
def _fetch_section_items_for_section(cur, section_id: int) -> List[Dict[str, Any]]:
|
||||
cur.execute(_SECTION_ITEMS_ROWS_SQL, (section_id,))
|
||||
items = [r2d(r) for r in cur.fetchall()]
|
||||
for it in items:
|
||||
_hydrate_section_item_combination_slots(cur, it)
|
||||
return items
|
||||
|
||||
|
||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Lädt alle Sektionen inkl. Items und Katalog-Anreicherung für die Einheit."""
|
||||
cur.execute(_SECTION_ROWS_SQL, (unit_id,))
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
return secs
|
||||
|
||||
|
|
@ -707,6 +718,7 @@ def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""GET-Payload: verschachtelte `sections` + abgeleitete flache `exercises` (Legacy-Kompatibilität)."""
|
||||
uid = unit["id"]
|
||||
unit["sections"] = _fetch_sections(cur, uid)
|
||||
_flatten_exercises_from_sections(unit)
|
||||
|
|
@ -874,31 +886,37 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
)
|
||||
|
||||
|
||||
def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_index: int) -> None:
|
||||
"""Eine Sektion inkl. Items einfügen (Ersetzungsbaum; keine Löschlogik)."""
|
||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = enumeration_index
|
||||
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
src_tsec,
|
||||
),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
_insert_section_items(cur, sid, sec.get("items"))
|
||||
|
||||
|
||||
def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
|
||||
"""Ersetzt den gesamten Sektionsbaum der Einheit (DELETE aller Sektionen + Neuaufbau)."""
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
for si, sec in enumerate(sections_in):
|
||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
src_tsec,
|
||||
),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
_insert_section_items(cur, sid, sec.get("items"))
|
||||
_insert_one_replacement_section(cur, unit_id, sec, si)
|
||||
|
||||
|
||||
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
||||
|
|
|
|||
159
backend/tests/test_training_planning_sections_integration.py
Normal file
159
backend/tests/test_training_planning_sections_integration.py
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
"""
|
||||
PostgreSQL-Integration: Roundtrip _replace_unit_sections ↔ _fetch_sections.
|
||||
|
||||
Aktivierung (lokal, analog zu test_access_layer_integration):
|
||||
set TRAINING_PLANNING_INTEGRATION=1
|
||||
pytest tests/test_training_planning_sections_integration.py -v -m integration
|
||||
|
||||
Voraussetzung: migrierte DB, DB_* wie Docker-Compose.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
|
||||
from db import get_db, get_cursor
|
||||
from routers.training_planning import _fetch_sections, _replace_unit_sections
|
||||
|
||||
|
||||
def _integration_enabled() -> bool:
|
||||
return os.getenv("TRAINING_PLANNING_INTEGRATION", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
pytestmark = [
|
||||
pytest.mark.integration,
|
||||
pytest.mark.skipif(
|
||||
not _integration_enabled(),
|
||||
reason="TRAINING_PLANNING_INTEGRATION=1 und PostgreSQL (DB_*) erforderlich",
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
def _db_ping() -> bool:
|
||||
try:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT 1 AS ok")
|
||||
row = cur.fetchone()
|
||||
return row is not None and row.get("ok") == 1
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def db_ready():
|
||||
if not _db_ping():
|
||||
pytest.skip("PostgreSQL nicht erreichbar (DB_HOST/DB_PORT/…)")
|
||||
|
||||
|
||||
def test_replace_sections_roundtrip(db_ready):
|
||||
"""INSERT-Hilfsdaten, replace, fetch — gleiche Semantik wie produktiver PUT-Pfad."""
|
||||
suffix = uuid.uuid4().hex[:12]
|
||||
club_name = f"tpl_it_club_{suffix}"
|
||||
email = f"tpl_it_{suffix}@test.local"
|
||||
|
||||
from auth import hash_pin
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"INSERT INTO clubs (name, abbreviation, status) VALUES (%s, %s, %s) RETURNING id",
|
||||
(club_name, "T", "active"),
|
||||
)
|
||||
club_id = int(cur.fetchone()["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO profiles (email, pin_hash, name, role, active_club_id)
|
||||
VALUES (%s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(email, hash_pin("x"), f"TP {suffix}", "trainer", club_id),
|
||||
)
|
||||
profile_id = int(cur.fetchone()["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_groups (club_id, name, trainer_id, status)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(club_id, f"Gruppe {suffix}", profile_id, "active"),
|
||||
)
|
||||
group_id = int(cur.fetchone()["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO exercises (title, goal, execution, visibility, status, created_by)
|
||||
VALUES (%s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(f"Übung {suffix}", "Ziel", "Ablauf", "private", "draft", profile_id),
|
||||
)
|
||||
ex_id = int(cur.fetchone()["id"])
|
||||
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_units (
|
||||
group_id, planned_date, status, created_by
|
||||
) VALUES (%s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(group_id, "2026-06-01", "planned", profile_id),
|
||||
)
|
||||
unit_id = int(cur.fetchone()["id"])
|
||||
|
||||
sections_in = [
|
||||
{
|
||||
"title": "A1",
|
||||
"order_index": 0,
|
||||
"guidance_notes": "gn",
|
||||
"items": [
|
||||
{
|
||||
"item_type": "note",
|
||||
"order_index": 0,
|
||||
"note_body": "Hinweis",
|
||||
},
|
||||
{
|
||||
"item_type": "exercise",
|
||||
"order_index": 1,
|
||||
"exercise_id": ex_id,
|
||||
"planned_duration_min": 5,
|
||||
"notes": "n1",
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
"title": "B2",
|
||||
"order_index": 1,
|
||||
"items": [],
|
||||
},
|
||||
]
|
||||
_replace_unit_sections(cur, unit_id, sections_in)
|
||||
loaded = _fetch_sections(cur, unit_id)
|
||||
conn.commit()
|
||||
|
||||
try:
|
||||
assert len(loaded) == 2
|
||||
assert loaded[0]["title"] == "A1"
|
||||
assert loaded[0]["guidance_notes"] == "gn"
|
||||
assert loaded[0]["order_index"] == 0
|
||||
assert len(loaded[0]["items"]) == 2
|
||||
assert loaded[0]["items"][0]["item_type"] == "note"
|
||||
assert loaded[0]["items"][0]["note_body"] == "Hinweis"
|
||||
assert loaded[0]["items"][1]["item_type"] == "exercise"
|
||||
assert int(loaded[0]["items"][1]["exercise_id"]) == ex_id
|
||||
assert loaded[0]["items"][1]["planned_duration_min"] == 5
|
||||
assert loaded[1]["title"] == "B2"
|
||||
assert loaded[1]["items"] == []
|
||||
finally:
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||||
cur.execute("DELETE FROM exercises WHERE id = %s", (ex_id,))
|
||||
cur.execute("DELETE FROM training_groups WHERE id = %s", (group_id,))
|
||||
cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,))
|
||||
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
|
||||
conn.commit()
|
||||
32
backend/tests/test_training_planning_sections_pure.py
Normal file
32
backend/tests/test_training_planning_sections_pure.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
"""Unit-Tests ohne DB: abgeleitete Trainingseinheit-Payload-Helfer."""
|
||||
import pytest
|
||||
|
||||
from routers.training_planning import _flatten_exercises_from_sections
|
||||
|
||||
|
||||
def test_flatten_exercises_from_sections_order():
|
||||
unit = {
|
||||
"sections": [
|
||||
{
|
||||
"order_index": 1,
|
||||
"items": [
|
||||
{"order_index": 1, "item_type": "exercise", "exercise_id": 10},
|
||||
{"order_index": 0, "item_type": "note"},
|
||||
{"order_index": 2, "item_type": "exercise", "exercise_id": 20},
|
||||
],
|
||||
},
|
||||
{
|
||||
"order_index": 0,
|
||||
"items": [{"order_index": 0, "item_type": "exercise", "exercise_id": 5}],
|
||||
},
|
||||
]
|
||||
}
|
||||
_flatten_exercises_from_sections(unit)
|
||||
# Sektionen nach order_index; innerhalb nur exercise-Items nach order_index
|
||||
assert [x["exercise_id"] for x in unit["exercises"]] == [5, 10, 20]
|
||||
|
||||
|
||||
def test_flatten_exercises_from_sections_empty():
|
||||
unit = {"sections": []}
|
||||
_flatten_exercises_from_sections(unit)
|
||||
assert unit["exercises"] == []
|
||||
|
|
@ -11,6 +11,8 @@ import TrainingPlanningFrameworkImportModal from './TrainingPlanningFrameworkImp
|
|||
import TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
||||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
||||
/* Parallele Trainingsstreams (Breakout): Planungs-API bleibt flache sections/items bis Schema-Paket 1–2;
|
||||
Payload-Aufbau → buildSectionsPayload (trainingUnitSectionsForm); Backend _replace_unit_sections. */
|
||||
import {
|
||||
defaultSection,
|
||||
normalizeUnitToForm,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
|
||||
* Parallele Streams: Schritte aus flattenPlanTimeline (linear); Stream-/Phasenwahl später einzubinden.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
/**
|
||||
* Trainingsablauf anzeigen, drucken und lokal auf der Matte abhaken (Fortschritt im Browser gespeichert).
|
||||
* Parallele Streams: rendering nutzt sortedSections/sortedItems (trainingPlanUtils); Fortschritt pro unitId — später ggf. pro Stream.
|
||||
*/
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
|
|
|
|||
|
|
@ -20,7 +20,8 @@ export function itemStableKey(it, secOrder, ix) {
|
|||
return `${secOrder}-${it?.item_type || 'row'}-${ix}`
|
||||
}
|
||||
|
||||
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander. */
|
||||
/** Flache Reihenfolge wie auf der Matte: alle Notizen und Übungen nacheinander.
|
||||
* Parallele Streams: aktuell strikt linear (sortedSections × sortedItems); für Breakout phase/stream‑aware erweitern. */
|
||||
export function flattenPlanTimeline(unit) {
|
||||
const list = []
|
||||
sortedSections(unit).forEach((sec, si) => {
|
||||
|
|
|
|||
|
|
@ -398,6 +398,7 @@ export function parseMin(v) {
|
|||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
/** PUT /api/training-units/:id `sections` — flache order_index pro Einheit; Parallelität bricht am DB-UNIQUE (training_unit_id, order_index) bis Migration. */
|
||||
export function buildSectionsPayload(sections) {
|
||||
return sections.map((sec, si) => ({
|
||||
order_index: si,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user