Parlellsession- Plan #35
|
|
@ -484,6 +484,94 @@ def _normalize_assistant_trainer_profile_ids(
|
||||||
)
|
)
|
||||||
return uniq
|
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]]:
|
def _normalize_planning_method_profile_payload(raw) -> Optional[Dict[str, Any]]:
|
||||||
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
|
"""None = Katalog wirksam; Dict = Snapshot fuer diese Platzierung."""
|
||||||
if raw is None:
|
if raw is None:
|
||||||
|
|
@ -702,51 +790,88 @@ def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
def _clone_section_payload_dict(sec: Dict[str, Any]) -> Dict[str, Any]:
|
||||||
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
|
"""Sektion inkl. Items ohne DB-IDs (für phases-Payload / Kopie)."""
|
||||||
secs = _fetch_sections(cur, unit_id)
|
items_clean: List[Dict[str, Any]] = []
|
||||||
out: List[Dict[str, Any]] = []
|
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
||||||
for sec in secs:
|
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||||||
items_clean: List[Dict[str, Any]] = []
|
oix = it.get("order_index")
|
||||||
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
if itype == "note":
|
||||||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
note_item = {
|
||||||
oix = it.get("order_index")
|
"item_type": "note",
|
||||||
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",
|
|
||||||
"order_index": oix,
|
"order_index": oix,
|
||||||
"exercise_id": it["exercise_id"],
|
"note_body": it.get("note_body") or "",
|
||||||
"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"))
|
sm = _optional_source_training_module_id_payload(it.get("source_training_module_id"))
|
||||||
if sm is not None:
|
if sm is not None:
|
||||||
ex_item["source_training_module_id"] = sm
|
note_item["source_training_module_id"] = sm
|
||||||
items_clean.append(ex_item)
|
items_clean.append(note_item)
|
||||||
out.append(
|
continue
|
||||||
{
|
if itype != "exercise" or not it.get("exercise_id"):
|
||||||
"title": sec.get("title"),
|
continue
|
||||||
"order_index": sec.get("order_index"),
|
ex_item = {
|
||||||
"guidance_notes": sec.get("guidance_notes"),
|
"item_type": "exercise",
|
||||||
"items": items_clean,
|
"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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -757,6 +882,7 @@ def _copy_blueprint_into_scheduled_unit(
|
||||||
planned_date: str,
|
planned_date: str,
|
||||||
profile_id: int,
|
profile_id: int,
|
||||||
origin_framework_slot_id: Optional[int],
|
origin_framework_slot_id: Optional[int],
|
||||||
|
role: str,
|
||||||
) -> int:
|
) -> int:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
|
|
@ -812,8 +938,8 @@ def _copy_blueprint_into_scheduled_unit(
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
|
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
|
||||||
nu = row["id"]
|
nu = row["id"]
|
||||||
cloned = _sections_clone_payload(cur, blueprint_unit_id)
|
cloned = _phases_clone_payload(cur, blueprint_unit_id)
|
||||||
_replace_unit_sections(cur, nu, cloned)
|
_replace_unit_phases(cur, nu, cloned, profile_id, role, profile_id)
|
||||||
return nu
|
return nu
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -859,6 +985,52 @@ def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: in
|
||||||
return int(r["id"])
|
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(
|
def _append_copied_module_items_to_section(
|
||||||
cur,
|
cur,
|
||||||
section_id: int,
|
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]:
|
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
||||||
cur.execute(
|
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(
|
def apply_training_module_to_training_unit(
|
||||||
unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)
|
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
|
profile_id = tenant.profile_id
|
||||||
role = tenant.global_role
|
role = tenant.global_role
|
||||||
if not _has_planning_role(role):
|
if not _has_planning_role(role):
|
||||||
|
|
@ -1935,12 +2245,44 @@ def apply_training_module_to_training_unit(
|
||||||
if section_order_index < 0:
|
if section_order_index < 0:
|
||||||
raise HTTPException(status_code=400, detail="section_order_index ungültig")
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
unit_row = _training_unit_guard_row(cur, unit_id)
|
unit_row = _training_unit_guard_row(cur, unit_id)
|
||||||
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
_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)
|
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)
|
_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)
|
_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"]
|
unit_id = cur.fetchone()["id"]
|
||||||
|
|
||||||
|
_assert_single_plan_content_key_create(data)
|
||||||
|
phases_in = data.get("phases")
|
||||||
sections_in = data.get("sections")
|
sections_in = data.get("sections")
|
||||||
exercises_in = data.get("exercises")
|
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)
|
_replace_unit_sections(cur, unit_id, sections_in)
|
||||||
elif tpl_id_safe:
|
elif tpl_id_safe:
|
||||||
_instantiate_from_template(cur, unit_id, 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)
|
_instantiate_from_template(cur, unit_id, tid)
|
||||||
content_handled = True
|
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 [])
|
_replace_unit_sections(cur, unit_id, data["sections"] or [])
|
||||||
elif not content_handled and "exercises" in data:
|
elif not content_handled and "exercises" in data:
|
||||||
_clear_unit_plan_content(cur, unit_id)
|
_clear_unit_plan_content(cur, unit_id)
|
||||||
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
_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)
|
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||||
|
|
||||||
conn.commit()
|
conn.commit()
|
||||||
|
|
@ -2351,6 +2707,7 @@ def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext =
|
||||||
str(planned_date),
|
str(planned_date),
|
||||||
profile_id,
|
profile_id,
|
||||||
slot_id,
|
slot_id,
|
||||||
|
role,
|
||||||
)
|
)
|
||||||
|
|
||||||
_promote_private_exercises_used_in_unit(cur, new_id, profile_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:
|
Aktivierung:
|
||||||
- Lokal: TRAINING_PLANNING_INTEGRATION=1
|
- Lokal: TRAINING_PLANNING_INTEGRATION=1
|
||||||
|
|
@ -15,7 +15,12 @@ import uuid
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from db import get_db, get_cursor
|
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:
|
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 profiles WHERE id = %s", (profile_id,))
|
||||||
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
|
cur.execute("DELETE FROM clubs WHERE id = %s", (club_id,))
|
||||||
conn.commit()
|
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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.137"
|
APP_VERSION = "0.8.138"
|
||||||
BUILD_DATE = "2026-05-12"
|
BUILD_DATE = "2026-05-12"
|
||||||
DB_SCHEMA_VERSION = "20260515063"
|
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
|
"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_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",
|
"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)
|
"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",
|
"training_modules": "1.0.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
|
|
@ -36,6 +36,15 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.137",
|
||||||
"date": "2026-05-14",
|
"date": "2026-05-14",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user