chore(version): update version and changelog for release 0.8.138
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

- 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.
This commit is contained in:
Lars 2026-05-15 07:04:24 +02:00
parent 0d34c0a73d
commit 214f90d39b
3 changed files with 549 additions and 52 deletions

View File

@ -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)

View File

@ -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()

View File

@ -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",