diff --git a/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md index 15fb078..6801b07 100644 --- a/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md +++ b/.claude/docs/working/PARALLEL_TRAINING_STREAMS_ANALYSIS_AND_IMPLEMENTATION_PLAN.md @@ -77,8 +77,8 @@ Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis ## 6. Implementierungspakete (Überblick) 0. Spike DDL + Contract-Doku -1. Schema + Migration + hydrate/replace -2. PUT + Module + Clone (`from-framework-slot`) +1. **Erledigt (2026-05-14):** Migration **063** + `training_planning`: Phasen/Streams-Schema, Backfill whole_group, `GET` mit `phases`, Legacy-`sections`-PUT unverändert (eine whole_group-Phase). +2. PUT mit echten Parallelphasen / Streams, `apply-training-module` mit Stream-Kontext, `from-framework-slot`-Kopie 3. Planung UI 4. Run + Coach 5. Co-Trainer pro Stream diff --git a/backend/migrations/063_training_unit_phases_parallel_streams.sql b/backend/migrations/063_training_unit_phases_parallel_streams.sql new file mode 100644 index 0000000..0006b6e --- /dev/null +++ b/backend/migrations/063_training_unit_phases_parallel_streams.sql @@ -0,0 +1,85 @@ +-- Migration 063: Phasen und parallele Streams pro Trainingseinheit (Grundlage Breakout). +-- Bestehende Sektionen werden einer Default-whole_group-Phase zugeordnet. +-- UNIQUE (training_unit_id, order_index) auf Sektionen entfällt zugunsten +-- eindeutiger order_index je Phase bzw. je parallel_stream. + +-- ── Phasen ─────────────────────────────────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_phases ( + id SERIAL PRIMARY KEY, + training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE, + order_index INT NOT NULL, + phase_kind VARCHAR(20) NOT NULL CHECK (phase_kind IN ('whole_group', 'parallel')), + title VARCHAR(200), + guidance_notes TEXT, + UNIQUE (training_unit_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_phases_unit ON training_unit_phases(training_unit_id); + +-- ── Streams innerhalb einer Parallelphase ────────────────────────────────── +CREATE TABLE IF NOT EXISTS training_unit_parallel_streams ( + id SERIAL PRIMARY KEY, + phase_id INT NOT NULL REFERENCES training_unit_phases(id) ON DELETE CASCADE, + order_index INT NOT NULL, + title VARCHAR(200), + notes TEXT, + assigned_trainer_profile_ids JSONB, + UNIQUE (phase_id, order_index) +); + +CREATE INDEX IF NOT EXISTS idx_training_unit_parallel_streams_phase + ON training_unit_parallel_streams(phase_id); + +COMMENT ON COLUMN training_unit_parallel_streams.assigned_trainer_profile_ids IS + 'Optionale Co-Trainer-IDs (JSON-Array von Profil-IDs) für diese Teilstrecke; MVP+'; + +-- ── Sektionen: Zuordnung zu Phase (gemeinsam) oder Stream (parallel) ───── +ALTER TABLE training_unit_sections + ADD COLUMN IF NOT EXISTS phase_id INT REFERENCES training_unit_phases(id) ON DELETE CASCADE, + ADD COLUMN IF NOT EXISTS parallel_stream_id INT REFERENCES training_unit_parallel_streams(id) ON DELETE CASCADE; + +-- Backfill: je Einheit mit Sektionen eine whole_group-Phase, alle Sektionen dorthin +INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title) +SELECT tu.id, 0, 'whole_group', NULL +FROM training_units tu +WHERE EXISTS (SELECT 1 FROM training_unit_sections s WHERE s.training_unit_id = tu.id) + AND NOT EXISTS ( + SELECT 1 FROM training_unit_phases p + WHERE p.training_unit_id = tu.id AND p.order_index = 0 AND p.phase_kind = 'whole_group' +); + +UPDATE training_unit_sections tus +SET phase_id = p.id +FROM training_unit_phases p +WHERE tus.phase_id IS NULL + AND p.training_unit_id = tus.training_unit_id + AND p.order_index = 0 + AND p.phase_kind = 'whole_group'; + +-- Alte globale Reihenfolge-Eindeutigkeit pro Einheit entfernen +ALTER TABLE training_unit_sections + DROP CONSTRAINT IF EXISTS training_unit_sections_training_unit_id_order_index_key; + +-- Genau eine Zielspalte gesetzt: gemeinsame Phase ODER paralleler Stream +ALTER TABLE training_unit_sections + DROP CONSTRAINT IF EXISTS training_unit_sections_phase_or_stream_chk; + +ALTER TABLE training_unit_sections + ADD CONSTRAINT training_unit_sections_phase_or_stream_chk CHECK ( + (phase_id IS NOT NULL AND parallel_stream_id IS NULL) + OR (phase_id IS NULL AND parallel_stream_id IS NOT NULL) + ); + +CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_phase_order + ON training_unit_sections (phase_id, order_index) + WHERE phase_id IS NOT NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_stream_order + ON training_unit_sections (parallel_stream_id, order_index) + WHERE parallel_stream_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_sections_phase + ON training_unit_sections(phase_id) WHERE phase_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_sections_parallel_stream + ON training_unit_sections(parallel_stream_id) WHERE parallel_stream_id IS NOT NULL; diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index 99794ae..dc05a1f 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -524,11 +524,48 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]: # ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ────────────────── # Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich. + + +def _clear_unit_plan_content(cur, unit_id: int) -> None: + """Löscht alle Planungs-Phasen der Einheit (CASCADE: Streams, Sektionen, Items).""" + cur.execute("DELETE FROM training_unit_phases WHERE training_unit_id = %s", (unit_id,)) + + +def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0) -> int: + """Legt bei Bedarf eine whole_group-Phase an; gibt phase.id zurück.""" + cur.execute( + """ + SELECT id FROM training_unit_phases + WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s + LIMIT 1 + """, + (unit_id, order_index), + ) + row = cur.fetchone() + if row: + return int(row["id"]) + cur.execute( + """ + INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes) + VALUES (%s, %s, 'whole_group', NULL, NULL) + RETURNING id + """, + (unit_id, order_index), + ) + return int(cur.fetchone()["id"]) + + _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 + SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes, + tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id + FROM training_unit_sections tus + LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id + LEFT JOIN training_unit_parallel_streams ps ON ps.id = tus.parallel_stream_id + LEFT JOIN training_unit_phases ph_s ON ph_s.id = ps.phase_id + WHERE tus.training_unit_id = %s + ORDER BY COALESCE(ph.order_index, ph_s.order_index) ASC, + ps.order_index ASC NULLS FIRST, + tus.order_index ASC """ _SECTION_ITEMS_ROWS_SQL = """ SELECT tusi.*, @@ -593,6 +630,78 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: return secs +def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]: + """Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen).""" + cur.execute( + """ + SELECT id, training_unit_id, order_index, phase_kind, title, guidance_notes + FROM training_unit_phases + WHERE training_unit_id = %s + ORDER BY order_index + """, + (unit_id,), + ) + out: List[Dict[str, Any]] = [] + for prow in cur.fetchall(): + p = r2d(prow) + pk = str(p.get("phase_kind") or "").strip().lower() + if pk == "whole_group": + cur.execute( + """ + SELECT id, training_unit_id, order_index, title, guidance_notes, + source_template_section_id, phase_id, parallel_stream_id + FROM training_unit_sections + WHERE phase_id = %s + ORDER BY order_index + """, + (p["id"],), + ) + secs: List[Dict[str, Any]] = [] + for srow in cur.fetchall(): + sec = r2d(srow) + sec["items"] = _fetch_section_items_for_section(cur, sec["id"]) + secs.append(sec) + p["sections"] = secs + p["streams"] = [] + elif pk == "parallel": + p["sections"] = [] + cur.execute( + """ + SELECT id, phase_id, order_index, title, notes, assigned_trainer_profile_ids + FROM training_unit_parallel_streams + WHERE phase_id = %s + ORDER BY order_index + """, + (p["id"],), + ) + streams: List[Dict[str, Any]] = [] + for st_row in cur.fetchall(): + st = r2d(st_row) + cur.execute( + """ + SELECT id, training_unit_id, order_index, title, guidance_notes, + source_template_section_id, phase_id, parallel_stream_id + FROM training_unit_sections + WHERE parallel_stream_id = %s + ORDER BY order_index + """, + (st["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) + st["sections"] = secs + streams.append(st) + p["streams"] = streams + else: + p["sections"] = [] + p["streams"] = [] + out.append(p) + return out + + def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: """Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder).""" secs = _fetch_sections(cur, unit_id) @@ -718,18 +827,27 @@ 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).""" + """GET-Payload: `phases` (verschachtelt), flache `sections` + abgeleitete `exercises` (Legacy).""" uid = unit["id"] + unit["phases"] = _fetch_phases_nested(cur, uid) unit["sections"] = _fetch_sections(cur, uid) _flatten_exercises_from_sections(unit) return unit def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int: + """Erste Sektion mit order_index in einer whole_group-Phase (Parallelstreams ausgeschlossen).""" cur.execute( """ - SELECT id FROM training_unit_sections - WHERE training_unit_id = %s AND order_index = %s + SELECT tus.id + FROM training_unit_sections tus + INNER JOIN training_unit_phases p ON p.id = tus.phase_id + WHERE tus.training_unit_id = %s + AND tus.order_index = %s + AND tus.parallel_stream_id IS NULL + AND LOWER(TRIM(p.phase_kind)) = 'whole_group' + ORDER BY p.order_index ASC, tus.id ASC + LIMIT 1 """, (unit_id, section_order_index), ) @@ -886,8 +1004,20 @@ 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).""" +def _insert_one_replacement_section( + cur, + unit_id: int, + sec: Any, + enumeration_index: int, + *, + phase_id: Optional[int] = None, + parallel_stream_id: Optional[int] = None, +) -> None: + """Eine Sektion inkl. Items (genau eines von phase_id / parallel_stream_id gesetzt).""" + if (phase_id is None) == (parallel_stream_id is None): + raise HTTPException( + status_code=500, detail="Intern: Sektion braucht phase_id oder parallel_stream_id" + ) title = (sec.get("title") or "").strip() or "Abschnitt" order_ix = sec.get("order_index") if order_ix is None: @@ -896,12 +1026,14 @@ def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_ind 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) + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id + ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( unit_id, + phase_id, + parallel_stream_id, order_ix, title, sec.get("guidance_notes"), @@ -913,10 +1045,13 @@ def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_ind 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,)) + """Ersetzt den gesamten Plan (Legacy): eine whole_group-Phase + Sektionen.""" + _clear_unit_plan_content(cur, unit_id) + phase_id = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) for si, sec in enumerate(sections_in): - _insert_one_replacement_section(cur, unit_id, sec, si) + _insert_one_replacement_section( + cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None + ) def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: @@ -1045,13 +1180,16 @@ def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]): if not exercises_in: return + pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) cur.execute( """ - INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) - VALUES (%s, 0, %s, NULL) + INSERT INTO training_unit_sections ( + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes + ) + VALUES (%s, %s, NULL, 0, %s, NULL) RETURNING id """, - (unit_id, "Übungen"), + (unit_id, pid, "Übungen"), ) sid = cur.fetchone()["id"] slot = 0 @@ -1080,6 +1218,8 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List def _instantiate_from_template(cur, unit_id: int, template_id: int): + _clear_unit_plan_content(cur, unit_id) + pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0) cur.execute( """ SELECT id, title, guidance_text @@ -1090,29 +1230,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int): (template_id,), ) rows = cur.fetchall() - for row in rows: + for gi, row in enumerate(rows): r = r2d(row) cur.execute( """ INSERT INTO training_unit_sections ( - training_unit_id, order_index, title, guidance_notes, source_template_section_id - ) VALUES (%s, ( - SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2 - WHERE u2.training_unit_id = %s - ), %s, %s, %s) + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id + ) VALUES (%s, %s, NULL, %s, %s, %s, %s) """, - (unit_id, unit_id, r["title"], r["guidance_text"], r["id"]), + (unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]), ) # Fallback: keine Sektionen in Vorlage → ein leerer Block if not rows: cur.execute( """ - INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) - SELECT %s, 0, %s, NULL - WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s) + INSERT INTO training_unit_sections ( + training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes + ) VALUES (%s, %s, NULL, 0, 'Hauptteil', NULL) """, - (unit_id, "Hauptteil", unit_id), + (unit_id, pid), ) @@ -2090,7 +2227,6 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request", ) _template_access(cur, tid, profile_id, role) - cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) cur.execute( "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) ) @@ -2100,7 +2236,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen if not content_handled and "sections" in data: _replace_unit_sections(cur, unit_id, data["sections"] or []) elif not content_handled and "exercises" in data: - cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) + _clear_unit_plan_content(cur, unit_id) _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) if content_handled or "sections" in data or "exercises" in data: diff --git a/backend/version.py b/backend/version.py index 6a2500b..33c0522 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.136" +APP_VERSION = "0.8.137" BUILD_DATE = "2026-05-12" -DB_SCHEMA_VERSION = "20260514062" +DB_SCHEMA_VERSION = "20260515063" MODULE_VERSIONS = { "legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste) @@ -24,7 +24,7 @@ MODULE_VERSIONS = { "exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break "training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak "training_programs": "0.1.0", - "planning": "0.9.5", # assistant_trainer_profile_ids: JSONB-Write mit PsycopgJson (Fix 500 bei Co-Liste) + "planning": "0.10.0", # Migration 063: training_unit_phases + parallel_streams; Sektionen phase_id|parallel_stream_id; GET phases + sections "dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine) "training_modules": "1.0.0", "import_wiki": "1.0.0", @@ -36,6 +36,14 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.137", + "date": "2026-05-14", + "changes": [ + "DB 063: training_unit_phases, training_unit_parallel_streams; Sektionen mit phase_id oder parallel_stream_id; Default whole_group für Bestand.", + "Planung: GET training_unit liefert phases (verschachtelt) + sections (flach sortiert); Legacy-PUT sections weiterhin eine whole_group-Phase.", + ], + }, { "version": "0.8.136", "date": "2026-05-13",