Parlellsession- Plan #35
|
|
@ -482,6 +482,8 @@ skill_level_definitions (
|
||||||
|
|
||||||
**Dokumentation:** `functional/PARALLEL_TRAINING_STREAMS_CONCEPT.md`, Umsetzung `technical/PARALLEL_TRAINING_STREAMS_SPEC.md`.
|
**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)
|
## 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
|
return i
|
||||||
|
|
||||||
|
|
||||||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ──────────────────
|
||||||
cur.execute(
|
# 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
|
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||||
FROM training_unit_sections
|
FROM training_unit_sections
|
||||||
WHERE training_unit_id = %s
|
WHERE training_unit_id = %s
|
||||||
ORDER BY order_index
|
ORDER BY order_index
|
||||||
""",
|
"""
|
||||||
(unit_id,),
|
_SECTION_ITEMS_ROWS_SQL = """
|
||||||
)
|
|
||||||
secs = []
|
|
||||||
for sec_row in cur.fetchall():
|
|
||||||
sec = r2d(sec_row)
|
|
||||||
cur.execute(
|
|
||||||
"""
|
|
||||||
SELECT tusi.*,
|
SELECT tusi.*,
|
||||||
e.title AS exercise_title,
|
e.title AS exercise_title,
|
||||||
e.exercise_kind AS exercise_kind,
|
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
|
LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id
|
||||||
WHERE tusi.section_id = %s
|
WHERE tusi.section_id = %s
|
||||||
ORDER BY tusi.order_index
|
ORDER BY tusi.order_index
|
||||||
""",
|
"""
|
||||||
(sec["id"],),
|
|
||||||
)
|
|
||||||
sec["items"] = [r2d(r) for r in cur.fetchall()]
|
def _hydrate_section_item_combination_slots(cur, it: Dict[str, Any]) -> None:
|
||||||
for it in sec["items"]:
|
"""Setzt `combination_slots` für Kombi‑Übungen; sonst leere Liste."""
|
||||||
if it.get("item_type") != "exercise":
|
if it.get("item_type") != "exercise":
|
||||||
continue
|
return
|
||||||
cmp_raw = it.get("catalog_method_profile")
|
cmp_raw = it.get("catalog_method_profile")
|
||||||
if not isinstance(cmp_raw, dict):
|
if not isinstance(cmp_raw, dict):
|
||||||
it["catalog_method_profile"] = {}
|
it["catalog_method_profile"] = {}
|
||||||
else:
|
else:
|
||||||
it["catalog_method_profile"] = dict(cmp_raw)
|
it["catalog_method_profile"] = dict(cmp_raw)
|
||||||
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
ek = str(it.get("exercise_kind") or "simple").strip().lower()
|
||||||
if ek == "combination" and it.get("exercise_id"):
|
if ek == "combination" and it.get("exercise_id"):
|
||||||
try:
|
try:
|
||||||
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
it["combination_slots"] = load_combination_slots_for_exercise(cur, int(it["exercise_id"]))
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
it["combination_slots"] = []
|
it["combination_slots"] = []
|
||||||
else:
|
else:
|
||||||
it["combination_slots"] = []
|
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)
|
secs.append(sec)
|
||||||
return secs
|
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]:
|
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"]
|
uid = unit["id"]
|
||||||
unit["sections"] = _fetch_sections(cur, uid)
|
unit["sections"] = _fetch_sections(cur, uid)
|
||||||
_flatten_exercises_from_sections(unit)
|
_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]):
|
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,))
|
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||||
for si, sec in enumerate(sections_in):
|
for si, sec in enumerate(sections_in):
|
||||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
_insert_one_replacement_section(cur, unit_id, sec, si)
|
||||||
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"))
|
|
||||||
|
|
||||||
|
|
||||||
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
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 TrainingPlanningModuleApplyModal from './TrainingPlanningModuleApplyModal'
|
||||||
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
import TrainingPlanningTrainerAssignModal from './TrainingPlanningTrainerAssignModal'
|
||||||
import TrainingPlanningUnitFormModal from './TrainingPlanningUnitFormModal'
|
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 {
|
import {
|
||||||
defaultSection,
|
defaultSection,
|
||||||
normalizeUnitToForm,
|
normalizeUnitToForm,
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
/**
|
/**
|
||||||
* Coach-Modus: eine Position nach der anderen mit Assistentenhinweisen, Zeitnahme und optionaler Nachbereitung.
|
* 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 React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
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).
|
* 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 React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
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}`
|
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) {
|
export function flattenPlanTimeline(unit) {
|
||||||
const list = []
|
const list = []
|
||||||
sortedSections(unit).forEach((sec, si) => {
|
sortedSections(unit).forEach((sec, si) => {
|
||||||
|
|
|
||||||
|
|
@ -398,6 +398,7 @@ export function parseMin(v) {
|
||||||
return Number.isFinite(n) ? n : null
|
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) {
|
export function buildSectionsPayload(sections) {
|
||||||
return sections.map((sec, si) => ({
|
return sections.map((sec, si) => ({
|
||||||
order_index: si,
|
order_index: si,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user