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
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:
parent
0d34c0a73d
commit
214f90d39b
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user