From 214f90d39bc921e91b14840ad64b7bf354d308b5 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 15 May 2026 07:04:24 +0200 Subject: [PATCH] chore(version): update version and changelog for release 0.8.138 - Bumped APP_VERSION to 0.8.138 and updated the changelog to reflect recent changes. - Enhanced training unit planning with support for POST/PUT requests including phases and parallel streams. - Fixed role assignment validation for stream co-trainers and added integration tests for phase handling. - Updated the training planning API to improve data structure and retrieval for nested phases and sections. --- backend/routers/training_planning.py | 453 ++++++++++++++++-- ..._training_planning_sections_integration.py | 135 +++++- backend/version.py | 13 +- 3 files changed, 549 insertions(+), 52 deletions(-) diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index dc05a1f..69cf66d 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -484,6 +484,94 @@ def _normalize_assistant_trainer_profile_ids( ) return uniq + +def _normalize_stream_assigned_trainer_profile_ids( + cur, + raw_val: Any, + *, + group_id: Optional[int], + profile_id: int, + role: str, + unit_created_by: Optional[int], + eff_lead_nid: Optional[int], +) -> Any: + """ + JSONB-Liste für training_unit_parallel_streams.assigned_trainer_profile_ids. + Ohne group_id (Rahmen-Blueprint): nur Profil-Existenz + keine Überschneidung mit Leitung. + Mit group_id: gleiche Vereins-/Zuweisungsregeln wie assistant_trainer_profile_ids. + """ + if raw_val is None: + return None + if not isinstance(raw_val, list): + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) muss Liste oder null sein", + ) + ids_in: List[int] = [] + for x in raw_val: + try: + i = int(x) + except (TypeError, ValueError): + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) ungültig", + ) + if i < 1: + raise HTTPException( + status_code=400, + detail="assigned_trainer_profile_ids (Stream) ungültig", + ) + ids_in.append(i) + uniq = sorted(set(ids_in)) + for nid in uniq: + cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) + if not cur.fetchone(): + raise HTTPException( + status_code=400, + detail="Profil für Stream-Co-Trainer nicht gefunden", + ) + if eff_lead_nid is not None and nid == eff_lead_nid: + raise HTTPException( + status_code=400, + detail="Leitung und Stream-Co-Trainer dürfen sich nicht überschneiden", + ) + if group_id is None: + return uniq + cur.execute( + "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", + (group_id,), + ) + gr = cur.fetchone() + if not gr: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + grd = dict(gr) + cid = grd.get("club_id") + if cid is None: + raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") + club_i = int(cid) + if not is_platform_admin(role) and not _caller_may_assign_session_trainers( + cur, grd, profile_id, role, unit_created_by + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer (Stream) zuzuweisen") + eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set() + for x in grd.get("co_trainer_ids") or []: + try: + eligible.add(int(x)) + except (TypeError, ValueError): + continue + for nid in uniq: + if is_platform_admin(role): + continue + if nid in eligible: + continue + if not _profile_active_in_club(cur, club_i, nid): + raise HTTPException( + status_code=400, + detail="Stream-Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe", + ) + return uniq + + def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]: """None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung.""" if raw is None: @@ -702,51 +790,88 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]: 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) - out: List[Dict[str, Any]] = [] - for sec in secs: - items_clean: List[Dict[str, Any]] = [] - for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): - itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") - oix = it.get("order_index") - if itype == "note": - note_item = { - "item_type": "note", - "order_index": oix, - "note_body": it.get("note_body") or "", - } - sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) - if sm is not None: - note_item["source_training_module_id"] = sm - items_clean.append(note_item) - continue - if itype != "exercise" or not it.get("exercise_id"): - continue - ex_item = { - "item_type": "exercise", +def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]: + """Sektion inkl. Items ohne DB-IDs (für phases-Payload / Kopie).""" + items_clean: List[Dict[str, Any]] = [] + for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)): + itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note") + oix = it.get("order_index") + if itype == "note": + note_item = { + "item_type": "note", "order_index": oix, - "exercise_id": it["exercise_id"], - "exercise_variant_id": it.get("exercise_variant_id"), - "planned_duration_min": it.get("planned_duration_min"), - "actual_duration_min": it.get("actual_duration_min"), - "notes": it.get("notes"), - "modifications": it.get("modifications"), - "planning_method_profile": it.get("planning_method_profile"), + "note_body": it.get("note_body") or "", } sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) if sm is not None: - ex_item["source_training_module_id"] = sm - items_clean.append(ex_item) - out.append( - { - "title": sec.get("title"), - "order_index": sec.get("order_index"), - "guidance_notes": sec.get("guidance_notes"), - "items": items_clean, - } - ) + note_item["source_training_module_id"] = sm + items_clean.append(note_item) + continue + if itype != "exercise" or not it.get("exercise_id"): + continue + ex_item = { + "item_type": "exercise", + "order_index": oix, + "exercise_id": it["exercise_id"], + "exercise_variant_id": it.get("exercise_variant_id"), + "planned_duration_min": it.get("planned_duration_min"), + "actual_duration_min": it.get("actual_duration_min"), + "notes": it.get("notes"), + "modifications": it.get("modifications"), + "planning_method_profile": it.get("planning_method_profile"), + } + sm = _optional_source_training_module_id_payload(it.get("source_training_module_id")) + if sm is not None: + ex_item["source_training_module_id"] = sm + items_clean.append(ex_item) + row: Dict[str, Any] = { + "title": sec.get("title"), + "order_index": sec.get("order_index"), + "guidance_notes": sec.get("guidance_notes"), + "items": items_clean, + } + stid = sec.get("source_template_section_id") + if stid is not None and stid != "": + try: + stid_i = int(stid) + if stid_i >= 1: + row["source_template_section_id"] = stid_i + except (TypeError, ValueError): + pass + return row + + +def _phases_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]: + """Vollständige Phasen/Streams/Sektionen für tiefe Kopie (ohne DB-IDs).""" + nested = _fetch_phases_nested(cur, unit_id) + out: List[Dict[str, Any]] = [] + for ph in nested: + kind = str(ph.get("phase_kind") or "").strip().lower() + if kind not in ("whole_group", "parallel"): + kind = "whole_group" + pd: Dict[str, Any] = { + "order_index": ph.get("order_index"), + "phase_kind": kind, + "title": ph.get("title"), + "guidance_notes": ph.get("guidance_notes"), + } + if kind == "whole_group": + pd["sections"] = [_clone_section_payload_dict(s) for s in ph.get("sections") or []] + pd["streams"] = [] + else: + pd["sections"] = [] + streams_clean: List[Dict[str, Any]] = [] + for st in ph.get("streams") or []: + sd: Dict[str, Any] = { + "order_index": st.get("order_index"), + "title": st.get("title"), + "notes": st.get("notes"), + "assigned_trainer_profile_ids": st.get("assigned_trainer_profile_ids"), + "sections": [_clone_section_payload_dict(s) for s in st.get("sections") or []], + } + streams_clean.append(sd) + pd["streams"] = streams_clean + out.append(pd) return out @@ -757,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit( planned_date: str, profile_id: int, origin_framework_slot_id: Optional[int], + role: str, ) -> int: cur.execute( """ @@ -812,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit( if not row: raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden") nu = row["id"] - cloned = _sections_clone_payload(cur, blueprint_unit_id) - _replace_unit_sections(cur, nu, cloned) + cloned = _phases_clone_payload(cur, blueprint_unit_id) + _replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id) return nu @@ -859,6 +985,52 @@ def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: in return int(r["id"]) +def _resolve_training_unit_section_id_for_apply( + cur, + unit_id: int, + section_order_index: int, + *, + phase_order_index: Optional[int], + parallel_stream_order_index: Optional[int], +) -> int: + """Ziel-Abschnitt: ganzes Gruppen physisch (nur section_order_index) oder innerhalb eines Parallelstreams.""" + if parallel_stream_order_index is None: + return _resolve_training_unit_section_id(cur, unit_id, section_order_index) + if phase_order_index is None: + raise HTTPException( + status_code=400, + detail="phase_order_index ist bei parallel_stream_order_index Pflicht", + ) + cur.execute( + """ + SELECT tus.id + FROM training_unit_sections tus + INNER JOIN training_unit_parallel_streams st ON st.id = tus.parallel_stream_id + INNER JOIN training_unit_phases p ON p.id = st.phase_id + WHERE tus.training_unit_id = %s + AND tus.order_index = %s + AND st.order_index = %s + AND p.order_index = %s + AND LOWER(TRIM(p.phase_kind)) = 'parallel' + ORDER BY tus.id ASC + LIMIT 1 + """, + ( + unit_id, + section_order_index, + parallel_stream_order_index, + phase_order_index, + ), + ) + r = cur.fetchone() + if not r: + raise HTTPException( + status_code=400, + detail="Abschnitt im Parallelstream für diese Indizes nicht gefunden", + ) + return int(r["id"]) + + def _append_copied_module_items_to_section( cur, section_id: int, @@ -1054,6 +1226,140 @@ def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): ) +def _replace_unit_phases( + cur, + unit_id: int, + phases_in: List[Any], + profile_id: int, + role: str, + unit_created_by: Optional[int], +) -> None: + """Ersetzt Phasen inkl. paralleler Streams und Sektionen (voller Plan).""" + if not isinstance(phases_in, list): + raise HTTPException(status_code=400, detail="phases muss eine Liste sein") + + cur.execute( + """ + SELECT tu.group_id, + COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS eff_lead + FROM training_units tu + LEFT JOIN training_groups tg ON tg.id = tu.group_id + WHERE tu.id = %s + """, + (unit_id,), + ) + ur = cur.fetchone() + group_id_opt = int(ur["group_id"]) if ur and ur.get("group_id") is not None else None + eff_lead_raw = ur.get("eff_lead") if ur else None + eff_lead_nid = int(eff_lead_raw) if eff_lead_raw is not None else None + + _clear_unit_plan_content(cur, unit_id) + for pi, ph in enumerate(phases_in): + kind = str(ph.get("phase_kind") or "").strip().lower() + if kind not in ("whole_group", "parallel"): + raise HTTPException( + status_code=400, + detail="phase_kind muss whole_group oder parallel sein", + ) + p_oix = ph.get("order_index") + if p_oix is None: + p_oix = pi + cur.execute( + """ + INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + unit_id, + int(p_oix), + kind, + ph.get("title"), + ph.get("guidance_notes"), + ), + ) + phase_id = int(cur.fetchone()["id"]) + if kind == "whole_group": + secs = ph.get("sections") + if secs is None: + secs = [] + if not isinstance(secs, list): + raise HTTPException(status_code=400, detail="sections muss Liste sein") + for si, sec in enumerate(secs): + _insert_one_replacement_section( + cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None + ) + else: + streams = ph.get("streams") + if streams is None: + streams = [] + if not isinstance(streams, list): + raise HTTPException(status_code=400, detail="streams muss Liste sein") + for si, st in enumerate(streams): + raw_asst = st.get("assigned_trainer_profile_ids") + asst_norm = _normalize_stream_assigned_trainer_profile_ids( + cur, + raw_asst, + group_id=group_id_opt, + profile_id=profile_id, + role=role, + unit_created_by=unit_created_by, + eff_lead_nid=eff_lead_nid, + ) + asst_db = None if asst_norm is None else PsycopgJson(asst_norm) + st_oix = st.get("order_index") + if st_oix is None: + st_oix = si + cur.execute( + """ + INSERT INTO training_unit_parallel_streams ( + phase_id, order_index, title, notes, assigned_trainer_profile_ids + ) VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, + ( + phase_id, + int(st_oix), + st.get("title"), + st.get("notes"), + asst_db, + ), + ) + sid = int(cur.fetchone()["id"]) + secs = st.get("sections") + if secs is None: + secs = [] + if not isinstance(secs, list): + raise HTTPException( + status_code=400, + detail="sections (Stream) muss Liste sein", + ) + for ti, sec in enumerate(secs): + _insert_one_replacement_section( + cur, unit_id, sec, ti, phase_id=None, parallel_stream_id=sid + ) + + +def _assert_single_plan_content_key_create(data: dict) -> None: + """Höchstens ein Plan-Inhalt: phases | sections | exercises (Non-None).""" + n = sum(1 for k in ("phases", "sections", "exercises") if data.get(k) is not None) + if n > 1: + raise HTTPException( + status_code=400, + detail="Nur eines von phases, sections oder exercises angeben", + ) + + +def _assert_single_plan_content_key_update(data: dict) -> None: + """PUT: höchstens einer der Keys phases | sections | exercises.""" + keys = [k for k in ("phases", "sections", "exercises") if k in data] + if len(keys) > 1: + raise HTTPException( + status_code=400, + detail="Nur eines von phases, sections oder exercises im Body gleichzeitig", + ) + + def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: cur.execute( """ @@ -1913,7 +2219,11 @@ def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_c def apply_training_module_to_training_unit( unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context) ): - """Kopiert die Positionen eines Trainingsmoduls ans Ende eines Abschnitts (lokal bearbeitbar).""" + """Kopiert Modul-Positionen ans Ende eines Abschnitts. + + Ziel: `section_order_index` in einer whole_group-Phase (Standard) oder + zusätzlich `phase_order_index` + `parallel_stream_order_index` für einen Stream. + """ profile_id = tenant.profile_id role = tenant.global_role if not _has_planning_role(role): @@ -1935,12 +2245,44 @@ def apply_training_module_to_training_unit( if section_order_index < 0: raise HTTPException(status_code=400, detail="section_order_index ungültig") + ps_raw = data.get("parallel_stream_order_index") + parallel_stream_oi: Optional[int] = None + if ps_raw is not None and ps_raw != "": + try: + parallel_stream_oi = int(ps_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig") + if parallel_stream_oi < 0: + raise HTTPException(status_code=400, detail="parallel_stream_order_index ungültig") + + phase_oi: Optional[int] = None + ph_raw = data.get("phase_order_index") + if ph_raw is not None and ph_raw != "": + try: + phase_oi = int(ph_raw) + except (TypeError, ValueError): + raise HTTPException(status_code=400, detail="phase_order_index ungültig") + if phase_oi < 0: + raise HTTPException(status_code=400, detail="phase_order_index ungültig") + + if phase_oi is not None and parallel_stream_oi is None: + raise HTTPException( + status_code=400, + detail="phase_order_index nur zusammen mit parallel_stream_order_index", + ) + with get_db() as conn: cur = get_cursor(conn) unit_row = _training_unit_guard_row(cur, unit_id) _assert_training_unit_permission(cur, unit_row, profile_id, role) - section_id = _resolve_training_unit_section_id(cur, unit_id, section_order_index) + section_id = _resolve_training_unit_section_id_for_apply( + cur, + unit_id, + section_order_index, + phase_order_index=phase_oi, + parallel_stream_order_index=parallel_stream_oi, + ) mod_items, src_mid = load_training_module_for_apply(cur, module_id, profile_id, role) _append_copied_module_items_to_section(cur, section_id, mod_items, src_mid) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) @@ -2048,10 +2390,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_ unit_id = cur.fetchone()["id"] + _assert_single_plan_content_key_create(data) + phases_in = data.get("phases") sections_in = data.get("sections") exercises_in = data.get("exercises") - if sections_in is not None: + if phases_in is not None: + _replace_unit_phases(cur, unit_id, phases_in, profile_id, role, profile_id) + elif sections_in is not None: _replace_unit_sections(cur, unit_id, sections_in) elif tpl_id_safe: _instantiate_from_template(cur, unit_id, tpl_id_safe) @@ -2233,13 +2579,23 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen _instantiate_from_template(cur, unit_id, tid) content_handled = True - if not content_handled and "sections" in data: + _assert_single_plan_content_key_update(data) + if not content_handled and "phases" in data: + _replace_unit_phases( + cur, + unit_id, + data.get("phases") or [], + profile_id, + role, + unit_row.get("created_by"), + ) + elif not content_handled and "sections" in data: _replace_unit_sections(cur, unit_id, data["sections"] or []) elif not content_handled and "exercises" in data: _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: + if content_handled or any(k in data for k in ("phases", "sections", "exercises")): _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() @@ -2351,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = str(planned_date), profile_id, slot_id, + role, ) _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) diff --git a/backend/tests/test_training_planning_sections_integration.py b/backend/tests/test_training_planning_sections_integration.py index fb625f6..5f71a11 100644 --- a/backend/tests/test_training_planning_sections_integration.py +++ b/backend/tests/test_training_planning_sections_integration.py @@ -1,5 +1,5 @@ """ -PostgreSQL-Integration: Roundtrip _replace_unit_sections ↔ _fetch_sections. +PostgreSQL-Integration: Roundtrip _replace_unit_sections / _replace_unit_phases ↔ Fetch-Helfer. Aktivierung: - Lokal: TRAINING_PLANNING_INTEGRATION=1 @@ -15,7 +15,12 @@ import uuid import pytest from db import get_db, get_cursor -from routers.training_planning import _fetch_sections, _replace_unit_sections +from routers.training_planning import ( + _fetch_phases_nested, + _fetch_sections, + _replace_unit_phases, + _replace_unit_sections, +) def _integration_enabled() -> bool: @@ -157,3 +162,129 @@ def test_replace_sections_roundtrip(db_ready): cur.execute("DELETE FROM profiles WHERE id = %s", (profile_id,)) cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,)) conn.commit() + + +def test_replace_phases_roundtrip_parallel_stream(db_ready): + """Phasen inkl. parallel-Stream-Sektionen ersetzen und wieder laden.""" + suffix = uuid.uuid4().hex[:12] + club_name = f"ph_it_club_{suffix}" + email = f"ph_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, "P", "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"PH {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 PH {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 PH {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-02", "planned", profile_id), + ) + unit_id = int(cur.fetchone()["id"]) + + phases_in = [ + { + "phase_kind": "whole_group", + "order_index": 0, + "title": "Aufwärmen", + "sections": [ + { + "title": "Gemeinsam", + "order_index": 0, + "items": [ + {"item_type": "note", "order_index": 0, "note_body": "Los"}, + ], + }, + ], + }, + { + "phase_kind": "parallel", + "order_index": 1, + "title": "Breakout", + "streams": [ + { + "order_index": 0, + "title": "Matte A", + "sections": [ + { + "title": "Technik A", + "order_index": 0, + "items": [ + { + "item_type": "exercise", + "order_index": 0, + "exercise_id": ex_id, + "planned_duration_min": 10, + }, + ], + }, + ], + }, + ], + }, + ] + _replace_unit_phases(cur, unit_id, phases_in, profile_id, "trainer", profile_id) + nested = _fetch_phases_nested(cur, unit_id) + flat_sec = _fetch_sections(cur, unit_id) + conn.commit() + + try: + assert len(nested) == 2 + assert nested[0]["phase_kind"] == "whole_group" + assert len(nested[0].get("sections") or []) == 1 + assert nested[1]["phase_kind"] == "parallel" + streams = nested[1].get("streams") or [] + assert len(streams) == 1 + assert len(streams[0].get("sections") or []) == 1 + assert streams[0]["sections"][0]["title"] == "Technik A" + assert len(streams[0]["sections"][0].get("items") or []) == 1 + assert int(streams[0]["sections"][0]["items"][0]["exercise_id"]) == ex_id + assert len(flat_sec) == 2 + 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() diff --git a/backend/version.py b/backend/version.py index 33c0522..f4798f4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.137" +APP_VERSION = "0.8.138" BUILD_DATE = "2026-05-12" DB_SCHEMA_VERSION = "20260515063" @@ -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.10.0", # Migration 063: training_unit_phases + parallel_streams; Sektionen phase_id|parallel_stream_id; GET phases + sections + "planning": "0.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index "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,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.138", + "date": "2026-05-14", + "changes": [ + "Planung Paket 2: POST/PUT training_units mit phases (voller Phasen-/Stream-Plan); höchstens eines von phases, sections, exercises pro Request; Rahmen-Blueprint→Termin kopiert verschachtelten Plan; apply-training-module optional phase_order_index + parallel_stream_order_index.", + "Fix: POST from-framework-slot übergibt role an _copy_blueprint_into_scheduled_unit (Stream-Trainer-Validierung).", + "Integrationstest test_replace_phases_roundtrip_parallel_stream.", + ], + }, { "version": "0.8.137", "date": "2026-05-14",