""" Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen) und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung). Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin. """ from datetime import date, timedelta from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException, Query from db import get_db, get_cursor, r2d from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql from club_tenancy import ( assert_valid_governance_visibility, can_manage_club_org, is_platform_admin, library_content_visible_to_profile, ) from routers.training_modules import load_training_module_for_apply router = APIRouter(prefix="/api", tags=["training_planning"]) def _has_planning_role(role: Optional[str]) -> bool: """Kann Trainingseinheiten/Vorlagen anlegen (bis Governance: auch einfacher Account).""" return role in ("admin", "superadmin", "trainer", "user") def _optional_positive_int(val, field_name: str) -> Optional[int]: if val is None or val == "": return None try: i = int(val) except (TypeError, ValueError): raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") if i < 1: raise HTTPException(status_code=400, detail=f"Ungültige {field_name}") return i def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): if not variant_id: return if not exercise_id: raise HTTPException( status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt" ) cur.execute( "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", (variant_id, exercise_id), ) if not cur.fetchone(): raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung") def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None: cur.execute( "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s", (group_id,), ) group = cur.fetchone() if not group: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") co_trainers = group["co_trainer_ids"] or [] if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen") if role not in ["admin", "superadmin"]: if group["trainer_id"] != profile_id and profile_id not in co_trainers: if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role): raise HTTPException( status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" ) def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool: cur.execute( """ SELECT 1 FROM club_members WHERE club_id = %s AND profile_id = %s AND status = 'active' LIMIT 1 """, (club_id, profile_id), ) return cur.fetchone() is not None def _caller_may_assign_session_trainers( cur, group_row: Dict[str, Any], profile_id: int, role: str, unit_created_by: Optional[int], ) -> bool: if is_platform_admin(role): return True cid = group_row.get("club_id") if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role): return True if unit_created_by is not None and unit_created_by == profile_id: return True if group_row.get("trainer_id") == profile_id: return True co = group_row.get("co_trainer_ids") or [] return profile_id in co def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]: """Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard.""" unit_asst = unit_row.get("assistant_trainer_profile_ids") if unit_asst is not None: src = unit_asst else: src = unit_row.get("co_trainer_ids") or [] seen: set = set() out: List[int] = [] for x in src: try: i = int(x) except (TypeError, ValueError): continue if i not in seen: seen.add(i) out.append(i) return sorted(out) def effective_co_trainer_profile_ids_for_merge( unit_assistant: Any, group_co: Any ) -> List[int]: """Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row.""" if unit_assistant is not None: src = unit_assistant else: src = group_co or [] seen: set = set() out: List[int] = [] for x in src: try: i = int(x) except (TypeError, ValueError): continue if i not in seen: seen.add(i) out.append(i) return sorted(out) def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]: cur.execute( """ SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id, tu.lead_trainer_profile_id, tu.assistant_trainer_profile_ids, tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id, fwp.created_by AS framework_created_by FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id WHERE tu.id = %s """, (unit_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") return r2d(row) def _assert_training_unit_permission( cur, unit_row: Dict[str, Any], profile_id: int, role: str ) -> None: if unit_row.get("framework_slot_id"): if role in ["admin", "superadmin"]: return if unit_row.get("created_by") == profile_id: return fw_by = unit_row.get("framework_created_by") if fw_by is not None and fw_by == profile_id: return raise HTTPException(status_code=403, detail="Keine Berechtigung") co_eff = _effective_co_trainer_ids_for_row(unit_row) if role in ["admin", "superadmin"]: return gcid = unit_row.get("group_club_id") if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role): return if ( unit_row["created_by"] != profile_id and unit_row["trainer_id"] != profile_id and profile_id not in co_eff and unit_row.get("lead_trainer_profile_id") != profile_id ): raise HTTPException(status_code=403, detail="Keine Berechtigung") def _assert_delete_training_unit( cur, role: str, created_by: int, profile_id: int, group_club_id: Optional[int], ) -> None: if role in ["admin", "superadmin"]: return if created_by == profile_id: return if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role): return raise HTTPException(status_code=403, detail="Keine Berechtigung") def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None: """Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE).""" if role in ("admin", "superadmin"): return if can_manage_club_org(cur, profile_id, club_id, role): return cur.execute( """ SELECT 1 FROM club_members WHERE club_id = %s AND profile_id = %s AND status = 'active' LIMIT 1 """, (club_id, profile_id), ) if cur.fetchone(): return cur.execute( """ SELECT 1 FROM training_groups g WHERE g.club_id = %s AND g.status = 'active' AND ( g.trainer_id = %s OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int)) ) LIMIT 1 """, (club_id, profile_id, profile_id), ) if not cur.fetchone(): raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein") def _normalize_lead_trainer_profile_id( cur, group_id: int, raw_lead: Any, profile_id: int, role: str, unit_created_by: Optional[int], ) -> Optional[int]: """NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug.""" if raw_lead is None: return None if raw_lead in ("", []): return None try: nid = int(raw_lead) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig") if nid < 1: raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig") cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,)) if not cur.fetchone(): raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden") 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 is_platform_admin(role): return nid 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 if nid == profile_id: if not _profile_active_in_club(cur, club_i, profile_id): raise HTTPException( status_code=403, detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen", ) return nid if nid not in eligible: if not _profile_active_in_club(cur, club_i, nid): raise HTTPException( status_code=400, detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe", ) if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by): raise HTTPException( status_code=403, detail="Keine Berechtigung, die Leitung zuzuweisen", ) return nid if nid != profile_id and not _caller_may_assign_session_trainers( cur, grd, profile_id, role, unit_created_by ): raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen") return nid def _normalize_assistant_trainer_profile_ids( cur, group_id: int, raw_val: Any, profile_id: int, role: str, unit_created_by: Optional[int], lead_nid: Optional[int], ) -> Any: """ None = Vererbung aus training_groups.co_trainer_ids (SQL NULL); Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.) """ if raw_val is None: return None if not isinstance(raw_val, list): raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids 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="assistant_trainer_profile_ids ungültig") if i < 1: raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig") ids_in.append(i) uniq = sorted(set(ids_in)) 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 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 eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None) 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 Co-Trainer nicht gefunden") if eff_lead is not None and nid == eff_lead: raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden") 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="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe", ) return uniq # Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id _ORIGIN_LINEAGE_JOIN = """ LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id """ _ORIGIN_LINEAGE_FIELDS = """ origin_fp.id AS origin_framework_program_id, origin_fp.title AS origin_framework_program_title, COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title, origin_slot.sort_order AS origin_framework_slot_sort_order """ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]: """Erlaubt None; sonst positives int (FK-Verletzung bei ungültigem Modul möglich).""" if raw_val is None or raw_val == "": return None try: i = int(raw_val) except (TypeError, ValueError): return None if i < 1: return None return i def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]: cur.execute( """ SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id FROM training_unit_sections WHERE training_unit_id = %s ORDER BY order_index """, (unit_id,), ) secs = [] for sec_row in cur.fetchall(): sec = r2d(sec_row) cur.execute( """ SELECT tusi.*, e.title AS exercise_title, e.summary AS exercise_summary, ( SELECT fa.name FROM exercise_focus_areas efa JOIN focus_areas fa ON fa.id = efa.focus_area_id WHERE efa.exercise_id = e.id ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC LIMIT 1 ) AS exercise_focus_area, ev.variant_name AS exercise_variant_name, tm.title AS source_module_title FROM training_unit_section_items tusi LEFT JOIN exercises e ON tusi.exercise_id = e.id LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id LEFT JOIN training_modules tm ON tm.id = tusi.source_training_module_id WHERE tusi.section_id = %s ORDER BY tusi.order_index """, (sec["id"],), ) sec["items"] = [r2d(r) for r in cur.fetchall()] secs.append(sec) return secs 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", "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"), } 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, } ) return out def _copy_blueprint_into_scheduled_unit( cur, blueprint_unit_id: int, group_id: int, planned_date: str, profile_id: int, origin_framework_slot_id: Optional[int], ) -> int: cur.execute( """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, planned_focus, actual_date, actual_time_start, actual_time_end, attendance_count, status, notes, trainer_notes, created_by, plan_template_id, origin_framework_slot_id, framework_slot_id ) SELECT %s, %s, planned_time_start, planned_time_end, planned_focus, NULL::DATE, NULL::TIME WITHOUT TIME ZONE, NULL::TIME WITHOUT TIME ZONE, NULL::INT, COALESCE(status, 'planned'), notes, trainer_notes, %s, NULL::INT, %s, NULL::INT FROM training_units WHERE id = %s AND framework_slot_id IS NOT NULL RETURNING id """, ( group_id, planned_date, profile_id, origin_framework_slot_id, blueprint_unit_id, ), ) row = cur.fetchone() 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) return nu def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None: flat: List[Dict[str, Any]] = [] for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)): for item in sorted(sec.get("items", []), key=lambda i: i.get("order_index", 0)): if item.get("item_type") == "exercise": flat.append(item) unit["exercises"] = flat def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]: uid = unit["id"] unit["sections"] = _fetch_sections(cur, uid) _flatten_exercises_from_sections(unit) return unit def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int: cur.execute( """ SELECT id FROM training_unit_sections WHERE training_unit_id = %s AND order_index = %s """, (unit_id, section_order_index), ) r = cur.fetchone() if not r: raise HTTPException( status_code=400, detail="Abschnitt für diese Reihenfolge nicht gefunden" ) return int(r["id"]) def _append_copied_module_items_to_section( cur, section_id: int, module_items: List[Dict[str, Any]], source_training_module_id: int, ) -> None: """Hängt kopierte Modul‑Items ans Ende eines Abschnitts (section_order_index in API).""" cur.execute( """ SELECT COALESCE(MAX(order_index), -1) AS mo FROM training_unit_section_items WHERE section_id = %s """, (section_id,), ) row = cur.fetchone() start = int(row["mo"]) + 1 if row and row["mo"] is not None else 0 for i, mi in enumerate(module_items): oi = start + i itype = mi.get("item_type") if itype == "note": body = mi.get("note_body") if body is None: body = "" cur.execute( """ INSERT INTO training_unit_section_items ( section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, notes, modifications, note_body, source_training_module_id ) VALUES (%s, %s, 'note', NULL, NULL, NULL, NULL, NULL, NULL, %s, %s) """, (section_id, oi, body, source_training_module_id), ) continue eid = mi.get("exercise_id") if not eid: continue eid = int(eid) vid = mi.get("exercise_variant_id") if vid is not None: vid = int(vid) else: vid = None _validate_variant_for_exercise(cur, eid, vid) cur.execute( """ INSERT INTO training_unit_section_items ( section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, notes, modifications, note_body, source_training_module_id ) VALUES (%s, %s, 'exercise', %s, %s, %s, NULL, %s, NULL, NULL, %s) """, ( section_id, oi, eid, vid, mi.get("planned_duration_min"), mi.get("notes"), source_training_module_id, ), ) def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0): if items_in is None: items_in = [] for i, raw in enumerate(items_in): itype = raw.get("item_type") if not itype: itype = "exercise" if raw.get("exercise_id") else "note" order_ix = raw.get("order_index") if order_ix is None: order_ix = start_order + i if itype == "note": body = raw.get("note_body") if body is None: body = "" src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id")) cur.execute( """ INSERT INTO training_unit_section_items ( section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, notes, modifications, note_body, source_training_module_id ) VALUES (%s, %s, 'note', NULL, NULL, NULL, NULL, NULL, NULL, %s, %s ) """, (section_id, order_ix, body, src_mod), ) continue eid = raw.get("exercise_id") if not eid: continue eid = int(eid) vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") _validate_variant_for_exercise(cur, eid, vid) src_mod = _optional_source_training_module_id_payload(raw.get("source_training_module_id")) cur.execute( """ INSERT INTO training_unit_section_items ( section_id, order_index, item_type, exercise_id, exercise_variant_id, planned_duration_min, actual_duration_min, notes, modifications, note_body, source_training_module_id ) VALUES (%s, %s, 'exercise', %s, %s, %s, %s, %s, %s, NULL, %s ) """, ( section_id, order_ix, eid, vid, raw.get("planned_duration_min"), raw.get("actual_duration_min"), raw.get("notes"), raw.get("modifications"), src_mod, ), ) def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]): cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) for si, sec in enumerate(sections_in): title = (sec.get("title") or "").strip() or "Abschnitt" order_ix = sec.get("order_index") if order_ix is None: order_ix = si src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id") cur.execute( """ INSERT INTO training_unit_sections ( training_unit_id, order_index, title, guidance_notes, source_template_section_id ) VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( unit_id, order_ix, title, sec.get("guidance_notes"), src_tsec, ), ) sid = cur.fetchone()["id"] _insert_section_items(cur, sid, sec.get("items")) def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]: cur.execute( """ SELECT DISTINCT tusi.exercise_id FROM training_unit_section_items tusi INNER JOIN training_unit_sections tus ON tusi.section_id = tus.id WHERE tus.training_unit_id = %s AND tusi.item_type = 'exercise' AND tusi.exercise_id IS NOT NULL """, (unit_id,), ) rows = cur.fetchall() or [] out: List[int] = [] for r in rows: try: out.append(int(r["exercise_id"])) except (TypeError, ValueError, KeyError): continue return out def _group_club_id_for_scheduled_unit(cur, unit_id: int) -> Optional[int]: """Nur echte Gruppentermine (keine Rahmen-Blueprints ohne Gruppe).""" cur.execute( """ SELECT tg.club_id FROM training_units tu INNER JOIN training_groups tg ON tu.group_id = tg.id WHERE tu.id = %s AND tu.framework_slot_id IS NULL """, (unit_id,), ) r = cur.fetchone() if not r or r.get("club_id") is None: return None return int(r["club_id"]) def _exercise_needs_club_visibility_for_target(ex: Dict[str, Any], target_club_id: int) -> bool: """Übung für Mitglieder des Ziel-Vereins in der Durchführung sichtbar machen (Dashboard/Queue).""" if str(ex.get("status") or "").strip().lower() == "archived": return False vis = (ex.get("visibility") or "private").strip().lower() if vis == "official": return False if vis == "private": return True if vis == "club": raw = ex.get("club_id") if raw is None: return True try: return int(raw) != int(target_club_id) except (TypeError, ValueError): return True return False def _caller_may_promote_exercise_to_club( cur, exercise_created_by: Optional[int], profile_id: int, role: str, target_club_id: int, ) -> bool: if is_platform_admin(role): return True if exercise_created_by is not None and int(exercise_created_by) == profile_id: return True if can_manage_club_org(cur, profile_id, target_club_id, role): return True return False def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int, role: str) -> None: """ Private Übungen in der Einheit auf visibility=club (Verein der Trainingsgruppe) setzen, damit andere Trainer und Mitglieder sie in der Durchführung sehen. """ target_club_id = _group_club_id_for_scheduled_unit(cur, unit_id) if not target_club_id: return if not ( is_platform_admin(role) or _profile_active_in_club(cur, target_club_id, profile_id) or can_manage_club_org(cur, profile_id, target_club_id, role) ): return for eid in _distinct_exercise_ids_in_unit(cur, unit_id): cur.execute( """ SELECT id, created_by, visibility, club_id, COALESCE(status, '') AS status FROM exercises WHERE id = %s """, (eid,), ) row = cur.fetchone() if not row: continue if str(row.get("status") or "").strip().lower() == "archived": continue vis = (row.get("visibility") or "private").strip().lower() if vis == "official": continue if vis == "club": continue if vis != "private": continue cb = row.get("created_by") if not _caller_may_promote_exercise_to_club(cur, cb, profile_id, role, target_club_id): continue cur.execute( """ UPDATE exercises SET visibility = 'club', club_id = %s, updated_at = NOW() WHERE id = %s AND LOWER(COALESCE(visibility, 'private')) = 'private' """, (target_club_id, eid), ) def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]): if not exercises_in: return cur.execute( """ INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) VALUES (%s, 0, %s, NULL) RETURNING id """, (unit_id, "Übungen"), ) sid = cur.fetchone()["id"] slot = 0 filtered: List[Dict[str, Any]] = [] for ex in exercises_in: eid = ex.get("exercise_id") if not eid: continue eid = int(eid) vid = _optional_positive_int(ex.get("exercise_variant_id"), "exercise_variant_id") _validate_variant_for_exercise(cur, eid, vid) filtered.append( { "item_type": "exercise", "order_index": slot, "exercise_id": eid, "exercise_variant_id": vid, "planned_duration_min": ex.get("planned_duration_min"), "actual_duration_min": ex.get("actual_duration_min"), "notes": ex.get("notes"), "modifications": ex.get("modifications"), } ) slot += 1 _insert_section_items(cur, sid, filtered, start_order=0) def _instantiate_from_template(cur, unit_id: int, template_id: int): cur.execute( """ SELECT id, title, guidance_text FROM training_plan_template_sections WHERE template_id = %s ORDER BY order_index """, (template_id,), ) rows = cur.fetchall() for row in rows: r = r2d(row) cur.execute( """ INSERT INTO training_unit_sections ( training_unit_id, order_index, title, guidance_notes, source_template_section_id ) VALUES (%s, ( SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2 WHERE u2.training_unit_id = %s ), %s, %s, %s) """, (unit_id, unit_id, r["title"], r["guidance_text"], r["id"]), ) # Fallback: keine Sektionen in Vorlage → ein leerer Block if not rows: cur.execute( """ INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes) SELECT %s, 0, %s, NULL WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s) """, (unit_id, "Hauptteil", unit_id), ) def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]: cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden") return r2d(r) def _template_assert_readable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: if is_platform_admin(role): return if not library_content_visible_to_profile( cur, profile_id, row.get("visibility") or "club", row.get("club_id"), row.get("created_by"), role, ): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Vorlage") def _template_assert_writable(cur, row: Dict[str, Any], profile_id: int, role: Optional[str]) -> None: if is_platform_admin(role): return if row.get("created_by") != profile_id: raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Vorlage ändern") def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]: """Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable.""" row = _fetch_training_plan_template_row(cur, tid) _template_assert_readable(cur, row, profile_id, role) return row # ── Vorlagen ──────────────────────────────────────────────────────────── @router.get("/training-plan-templates") def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) vis_clause, vis_params = library_content_visibility_sql( alias="t", profile_id=profile_id, role=role, effective_club_id=tenant.effective_club_id, ) cur.execute( f""" SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count FROM training_plan_templates t WHERE ({vis_clause}) ORDER BY t.updated_at DESC NULLS LAST, t.name """, vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-plan-templates/{template_id}") def get_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row = _template_access(cur, template_id, profile_id, role) cur.execute( """ SELECT * FROM training_plan_template_sections WHERE template_id = %s ORDER BY order_index """, (template_id,), ) row["sections"] = [r2d(r) for r in cur.fetchall()] return row @router.post("/training-plan-templates") def create_training_plan_template(data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Vorlagen anlegen") name = (data.get("name") or "").strip() if not name: raise HTTPException(status_code=400, detail="name ist Pflicht") vis_raw = data.get("visibility") visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club" club_id = data.get("club_id") if club_id in ("", []): club_id = None if visibility == "club" and club_id is None: club_id = tenant.effective_club_id sections_in = data.get("sections") or [] with get_db() as conn: cur = get_cursor(conn) assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id) cur.execute( """ INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility) VALUES (%s, %s, %s, %s, %s) RETURNING id """, (club_id, profile_id, name, data.get("description"), visibility), ) tid = cur.fetchone()["id"] for si, sec in enumerate(sections_in): title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" order_ix = sec.get("order_index") if order_ix is None: order_ix = si cur.execute( """ INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) VALUES (%s, %s, %s, %s) """, (tid, order_ix, title, sec.get("guidance_text")), ) conn.commit() return get_training_plan_template(tid, tenant) @router.put("/training-plan-templates/{template_id}") def update_training_plan_template(template_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row_prev = _fetch_training_plan_template_row(cur, template_id) _template_assert_writable(cur, row_prev, profile_id, role) merged_vis = row_prev.get("visibility") or "club" merged_club = row_prev.get("club_id") if "visibility" in data: v_in = data.get("visibility") if not isinstance(v_in, str) or v_in not in ("private", "club", "official"): raise HTTPException(status_code=400, detail="visibility ungültig") merged_vis = v_in if "club_id" in data: merged_club = data.get("club_id") if merged_club in ("", []): merged_club = None if "visibility" in data or "club_id" in data: assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) fields = [] params: List[Any] = [] if "name" in data: name = data.get("name") name = name.strip() if isinstance(name, str) else "" if not name: raise HTTPException(status_code=400, detail="name ist Pflicht") fields.append("name = %s") params.append(name) if "description" in data: fields.append("description = %s") params.append(data.get("description")) if "club_id" in data: fields.append("club_id = %s") params.append(merged_club) if "visibility" in data: fields.append("visibility = %s") params.append(merged_vis) fields.append("updated_at = NOW()") params.append(template_id) cur.execute( f""" UPDATE training_plan_templates SET {", ".join(fields)} WHERE id = %s """, tuple(params), ) if "sections" in data: cur.execute( "DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,) ) sections_in = data["sections"] or [] for si, sec in enumerate(sections_in): title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}" order_ix = sec.get("order_index") if order_ix is None: order_ix = si cur.execute( """ INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text) VALUES (%s, %s, %s, %s) """, (template_id, order_ix, title, sec.get("guidance_text")), ) conn.commit() return get_training_plan_template(template_id, tenant) @router.delete("/training-plan-templates/{template_id}") def delete_training_plan_template(template_id: int, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) row_del = _fetch_training_plan_template_row(cur, template_id) _template_assert_writable(cur, row_del, profile_id, role) cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,)) conn.commit() return {"ok": True} # ── Einheiten ───────────────────────────────────────────────────────────── @router.get("/training-units") def list_training_units( group_id: Optional[int] = Query(default=None), club_id: Optional[int] = Query(default=None), start_date: Optional[str] = Query(default=None), end_date: Optional[str] = Query(default=None), status: Optional[str] = Query(default=None), assigned_to_me: bool = Query(default=False), debrief_pending: bool = Query( default=False, description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)", ), sort: str = Query(default="desc"), limit: Optional[int] = Query(default=None), tenant: TenantContext = Depends(get_tenant_context), ): profile_id = tenant.profile_id role = tenant.global_role gid = _optional_positive_int(group_id, "group_id") if group_id else None cid = _optional_positive_int(club_id, "club_id") if club_id else None if gid and cid: raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben") with get_db() as conn: cur = get_cursor(conn) if cid and role not in ["admin", "superadmin"]: _assert_club_visible_for_trainer(cur, cid, profile_id, role) if gid and role not in ["admin", "superadmin"]: cur.execute( "SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'", (gid,), ) gr = cur.fetchone() if not gr: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") gd = dict(gr) cob = gd.get("co_trainer_ids") or [] ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role) ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id) if not (ok_staff or ok_org or ok_member): raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC" lim: Optional[int] = None if limit is not None: try: lim = int(limit) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="limit ungültig") if lim < 1: raise HTTPException(status_code=400, detail="limit ungültig") lim = min(lim, 250) query = """ SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, tg.club_id AS group_club_id, c.name as club_name, p.name as trainer_name, p.name as creator_name, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) AS effective_assistant_trainer_profile_ids, leadp.name AS lead_trainer_name """ query += "," + _ORIGIN_LINEAGE_FIELDS query += """ FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) """ query += _ORIGIN_LINEAGE_JOIN where = [] params = [] skip_involvement_filter = role in ("admin", "superadmin") if not skip_involvement_filter and cid is not None: if can_manage_club_org(cur, profile_id, cid, role): skip_involvement_filter = True if not skip_involvement_filter and gid is not None: cur.execute( "SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'", (gid,), ) gcx = cur.fetchone() if gcx and gcx.get("club_id") is not None: if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role): skip_involvement_filter = True if not skip_involvement_filter: where.append( "(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR " "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " "@> jsonb_build_array(%s::int))" ) params.extend([profile_id, profile_id, profile_id, profile_id]) where.append("tu.framework_slot_id IS NULL") if gid: where.append("tu.group_id = %s") params.append(gid) if cid: where.append("tg.club_id = %s") params.append(cid) if assigned_to_me: where.append( "(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR " "COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) " "@> jsonb_build_array(%s::int))" ) params.extend([profile_id, profile_id]) if start_date: where.append("tu.planned_date >= %s") params.append(start_date) if end_date: where.append("tu.planned_date <= %s") params.append(end_date) if debrief_pending: where.append("tu.status = %s") params.append("completed") where.append("tu.debrief_completed_at IS NULL") elif status: where.append("tu.status = %s") params.append(status) if where: query += " WHERE " + " AND ".join(where) query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST" if lim is not None: query += " LIMIT %s" params.append(lim) cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.get("/training-units/exercises-club-visibility-queue") def exercises_club_visibility_queue( start_date: Optional[str] = Query(default=None), end_date: Optional[str] = Query(default=None), assigned_to_me: bool = Query(default=True), limit_units: int = Query(default=80, ge=1, le=150), tenant: TenantContext = Depends(get_tenant_context), ): """ Übungen in deinen Trainingseinheiten (Zeitfenster), die für den jeweiligen Verein der Gruppe noch nicht vereinsweit sichtbar sind — für Dashboard & Freigabe-Workflow. """ profile_id = tenant.profile_id role = tenant.global_role if start_date is None: start_date = (date.today() - timedelta(days=45)).isoformat() if end_date is None: end_date = (date.today() + timedelta(days=365)).isoformat() units = list_training_units( group_id=None, club_id=None, start_date=start_date, end_date=end_date, status=None, assigned_to_me=assigned_to_me, debrief_pending=False, sort="asc", limit=limit_units, tenant=tenant, ) unit_ids = [int(u["id"]) for u in units if u.get("id") is not None] if not unit_ids: return {"items": []} placeholders = ",".join(["%s"] * len(unit_ids)) items: List[Dict[str, Any]] = [] with get_db() as conn: cur = get_cursor(conn) cur.execute( f""" SELECT DISTINCT tu.id AS unit_id, tu.planned_date, tg.name AS group_name, tg.club_id AS target_club_id, c.name AS target_club_name, tusi.exercise_id AS exercise_id FROM training_units tu INNER JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON c.id = tg.club_id INNER JOIN training_unit_sections tus ON tus.training_unit_id = tu.id INNER JOIN training_unit_section_items tusi ON tusi.section_id = tus.id WHERE tu.id IN ({placeholders}) AND tu.framework_slot_id IS NULL AND tusi.item_type = 'exercise' AND tusi.exercise_id IS NOT NULL """, tuple(unit_ids), ) pairs = [r2d(r) for r in cur.fetchall()] if not pairs: return {"items": []} ex_ids = sorted( {int(p["exercise_id"]) for p in pairs if p.get("exercise_id") is not None} ) if not ex_ids: return {"items": []} exercises_map: Dict[int, Dict[str, Any]] = {} ph = ",".join(["%s"] * len(ex_ids)) cur.execute( f""" SELECT id, title, visibility, club_id, created_by, status FROM exercises WHERE id IN ({ph}) """, tuple(ex_ids), ) for r in cur.fetchall(): d = r2d(r) exercises_map[int(d["id"])] = d agg: Dict[tuple, Dict[str, Any]] = {} for p in pairs: try: ex_id = int(p["exercise_id"]) except (TypeError, ValueError): continue tc_raw = p.get("target_club_id") if tc_raw is None: continue tc = int(tc_raw) key = (ex_id, tc) if key not in agg: agg[key] = { "exercise_id": ex_id, "target_club_id": tc, "target_club_name": (p.get("target_club_name") or "").strip(), "units": [], } uid = p.get("unit_id") if uid is None: continue agg[key]["units"].append( { "id": int(uid), "planned_date": str(p["planned_date"]) if p.get("planned_date") is not None else "", "group_name": (p.get("group_name") or "").strip(), } ) for _key, blob in agg.items(): ex_id = blob["exercise_id"] tc = blob["target_club_id"] ex = exercises_map.get(ex_id) if not ex: continue if not _exercise_needs_club_visibility_for_target(ex, tc): continue uniq_units = {u["id"]: u for u in blob["units"]}.values() ulist = sorted( uniq_units, key=lambda x: (x.get("planned_date") or "", x.get("id")), ) cb = ex.get("created_by") cb_int = int(cb) if cb is not None else None can_promote = _caller_may_promote_exercise_to_club(cur, cb_int, profile_id, role, tc) vis = (ex.get("visibility") or "private").strip().lower() st = (ex.get("status") or "draft").strip().lower() ecid = ex.get("club_id") items.append( { "exercise_id": ex_id, "title": (ex.get("title") or f"Übung #{ex_id}").strip() or f"Übung #{ex_id}", "visibility": vis, "status": st, "club_id": int(ecid) if ecid is not None else None, "created_by": cb_int, "target_club_id": tc, "target_club_name": blob.get("target_club_name") or "", "can_promote": can_promote, "units": ulist, } ) items.sort( key=lambda x: ( (x["units"][0].get("planned_date") if x["units"] else ""), x["title"], ) ) return {"items": items} @router.get("/training-units/{unit_id}") def get_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT tu.*, tg.name as group_name, tg.weekday as group_weekday, tg.time_start as group_time_start, tg.time_end as group_time_end, tg.location as group_location, c.name as club_name, p.name as trainer_name, p.name as creator_name, tg.trainer_id AS trainer_id, tg.co_trainer_ids AS co_trainer_ids, tg.club_id AS group_club_id, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) AS effective_assistant_trainer_profile_ids, leadp.name AS lead_trainer_name, """ + _ORIGIN_LINEAGE_FIELDS.strip() + """ FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id LEFT JOIN clubs c ON tg.club_id = c.id LEFT JOIN profiles p ON tu.created_by = p.id LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) """ + _ORIGIN_LINEAGE_JOIN.strip() + """ WHERE tu.id = %s """, (unit_id,), ) unit = cur.fetchone() if not unit: raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") unit = r2d(unit) if unit.get("framework_slot_id"): if role not in ["admin", "superadmin"]: cur.execute( """ SELECT fp.created_by FROM training_framework_slots s JOIN training_framework_programs fp ON fp.id = s.framework_program_id WHERE s.id = %s """, (unit["framework_slot_id"],), ) fr = cur.fetchone() cb = fr["created_by"] if fr else None if unit["created_by"] != profile_id and cb != profile_id: raise HTTPException(status_code=403, detail="Keine Berechtigung") else: if not unit.get("group_id"): raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") _assert_training_unit_permission(cur, unit, profile_id, role) _hydrate_training_unit_payload(cur, unit) return unit @router.post("/training-units/{unit_id}/apply-training-module") 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).""" profile_id = tenant.profile_id role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Module übernehmen") module_id_raw = data.get("module_id") if module_id_raw is None or module_id_raw == "": raise HTTPException(status_code=400, detail="module_id ist Pflicht") try: module_id = int(module_id_raw) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="module_id ungültig") soy = data.get("section_order_index") try: section_order_index = int(soy) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="section_order_index ist Pflicht (Ganzzahl)") if section_order_index < 0: raise HTTPException(status_code=400, detail="section_order_index ungültig") 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) 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) conn.commit() return get_training_unit(unit_id, tenant) @router.post("/training-units") def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role group_id = data.get("group_id") planned_date = data.get("planned_date") if not group_id or not planned_date: raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id") with get_db() as conn: cur = get_cursor(conn) _can_access_group_for_create(cur, group_id, profile_id, role) tpl_id_safe = None if plan_template_id: _template_access(cur, plan_template_id, profile_id, role) tpl_id_safe = plan_template_id cur.execute( "SELECT trainer_id FROM training_groups WHERE id = %s", (int(group_id),), ) g0 = cur.fetchone() default_group_trainer = g0["trainer_id"] if g0 else None lead_ins: Optional[int] = None if "lead_trainer_profile_id" in data: lead_ins = _normalize_lead_trainer_profile_id( cur, int(group_id), data.get("lead_trainer_profile_id"), profile_id, role, profile_id, ) assistant_val: Any = None assistant_set = False if "assistant_trainer_profile_ids" in data: assistant_set = True eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer assistant_val = _normalize_assistant_trainer_profile_ids( cur, int(group_id), data.get("assistant_trainer_profile_ids"), profile_id, role, profile_id, eff_lead_for_co, ) base_params = ( group_id, planned_date, data.get("planned_time_start"), data.get("planned_time_end"), data.get("planned_focus"), data.get("status", "planned"), data.get("notes"), data.get("trainer_notes"), profile_id, tpl_id_safe, lead_ins, ) if assistant_set: cur.execute( """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, planned_focus, status, notes, trainer_notes, created_by, plan_template_id, lead_trainer_profile_id, assistant_trainer_profile_ids ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, base_params + (assistant_val,), ) else: cur.execute( """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, planned_focus, status, notes, trainer_notes, created_by, plan_template_id, lead_trainer_profile_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, base_params, ) unit_id = cur.fetchone()["id"] sections_in = data.get("sections") exercises_in = data.get("exercises") if 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) elif exercises_in is not None: _insert_sections_from_legacy_exercises(cur, unit_id, exercises_in) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() return get_training_unit(unit_id, tenant) @router.put("/training-units/{unit_id}") def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role 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) is_blueprint = unit_row.get("framework_slot_id") is not None tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None tpl_id_val = None if tpl_upd not in (None, ""): tid = _optional_positive_int(tpl_upd, "plan_template_id") if tid: _template_access(cur, tid, profile_id, role) tpl_id_val = tid trainer_notes_val = None if "trainer_notes" not in data: cur.execute( "SELECT trainer_notes FROM training_units WHERE id = %s", (unit_id,), ) row_tn = cur.fetchone() trainer_notes_val = row_tn["trainer_notes"] if row_tn else None else: trainer_notes_val = data.get("trainer_notes") if is_blueprint: if data.get("reset_from_template"): raise HTTPException( status_code=400, detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden", ) if tpl_upd not in (None, ""): raise HTTPException( status_code=400, detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig", ) blueprint_fields = [] blueprint_params: List[Any] = [] if "planned_focus" in data: blueprint_fields.append("planned_focus = %s") blueprint_params.append(data.get("planned_focus")) if "planned_time_start" in data: blueprint_fields.append("planned_time_start = %s") blueprint_params.append(data.get("planned_time_start")) if "planned_time_end" in data: blueprint_fields.append("planned_time_end = %s") blueprint_params.append(data.get("planned_time_end")) if "notes" in data: blueprint_fields.append("notes = %s") blueprint_params.append(data.get("notes")) blueprint_fields.append("trainer_notes = %s") blueprint_params.append(trainer_notes_val) blueprint_params.append(unit_id) cur.execute( f""" UPDATE training_units SET {", ".join(blueprint_fields)}, updated_at = NOW() WHERE id = %s """, tuple(blueprint_params), ) else: cur_lead = unit_row.get("lead_trainer_profile_id") base_tr = unit_row.get("trainer_id") lead_sql = "" lead_params: List[Any] = [] assist_sql = "" assist_params: List[Any] = [] nl: Optional[int] if "lead_trainer_profile_id" in data: nl = _normalize_lead_trainer_profile_id( cur, unit_row["group_id"], data.get("lead_trainer_profile_id"), profile_id, role, unit_row.get("created_by"), ) lead_sql = ", lead_trainer_profile_id = %s" lead_params.append(nl) eff_lead_for_co = nl if nl is not None else base_tr else: nl = cur_lead if cur_lead is not None else base_tr eff_lead_for_co = nl if "assistant_trainer_profile_ids" in data: na = _normalize_assistant_trainer_profile_ids( cur, unit_row["group_id"], data.get("assistant_trainer_profile_ids"), profile_id, role, unit_row.get("created_by"), eff_lead_for_co, ) assist_sql = ", assistant_trainer_profile_ids = %s" assist_params.append(na) debrief_frag = "" if "debrief_completed" in data and not is_blueprint: if data.get("debrief_completed") is True: debrief_frag = ", debrief_completed_at = NOW()" else: debrief_frag = ", debrief_completed_at = NULL" cur.execute( f""" UPDATE training_units SET planned_date = COALESCE(%s, planned_date), planned_time_start = %s, planned_time_end = %s, planned_focus = %s, actual_date = %s, actual_time_start = %s, actual_time_end = %s, attendance_count = %s, status = %s, notes = %s, trainer_notes = %s, plan_template_id = COALESCE(%s, plan_template_id), updated_at = NOW() {lead_sql} {assist_sql} {debrief_frag} WHERE id = %s """, ( data.get("planned_date"), data.get("planned_time_start"), data.get("planned_time_end"), data.get("planned_focus"), data.get("actual_date"), data.get("actual_time_start"), data.get("actual_time_end"), data.get("attendance_count"), data.get("status"), data.get("notes"), trainer_notes_val, tpl_id_val, ) + tuple(lead_params) + tuple(assist_params) + (unit_id,), ) content_handled = False if not is_blueprint and data.get("reset_from_template"): tid = tpl_id_val or unit_row.get("plan_template_id") if not tid: raise HTTPException( status_code=400, detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request", ) _template_access(cur, tid, profile_id, role) cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) cur.execute( "UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id) ) _instantiate_from_template(cur, unit_id, tid) content_handled = True if not content_handled and "sections" in data: _replace_unit_sections(cur, unit_id, data["sections"] or []) elif not content_handled and "exercises" in data: cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,)) _insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or []) if content_handled or "sections" in data or "exercises" in data: _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() return get_training_unit(unit_id, tenant) @router.delete("/training-units/{unit_id}") def delete_training_unit(unit_id: int, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id FROM training_units tu LEFT JOIN training_groups tg ON tu.group_id = tg.id WHERE tu.id = %s """, (unit_id,), ) unit = cur.fetchone() if not unit: raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden") if unit.get("framework_slot_id"): raise HTTPException( status_code=400, detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.", ) _assert_delete_training_unit( cur, role, unit["created_by"], profile_id, unit.get("group_club_id"), ) cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,)) conn.commit() return {"ok": True} @router.post("/training-units/from-framework-slot") def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)): """Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id).""" profile_id = tenant.profile_id role = tenant.global_role if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Planungsberechtigte dürfen Trainingseinheiten erstellen") raw_sid = data.get("framework_slot_id") try: slot_id = int(raw_sid) except (TypeError, ValueError): raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") if slot_id < 1: raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig") group_id = data.get("group_id") planned_date = data.get("planned_date") if not group_id or not planned_date: raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT fp.created_by FROM training_framework_slots s JOIN training_framework_programs fp ON fp.id = s.framework_program_id WHERE s.id = %s """, (slot_id,), ) fw_row = cur.fetchone() if not fw_row: raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden") if role not in ["admin", "superadmin"]: if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id: raise HTTPException( status_code=403, detail="Keine Berechtigung für dieses Rahmenprogramm", ) cur.execute( "SELECT id FROM training_units WHERE framework_slot_id = %s", (slot_id,), ) blueprint = cur.fetchone() if not blueprint: raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot") _can_access_group_for_create(cur, int(group_id), profile_id, role) new_id = _copy_blueprint_into_scheduled_unit( cur, int(blueprint["id"]), int(group_id), str(planned_date), profile_id, slot_id, ) _promote_private_exercises_used_in_unit(cur, new_id, profile_id, role) conn.commit() return get_training_unit(new_id, tenant) @router.post("/training-units/quick-create") def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id group_id = data.get("group_id") planned_date = data.get("planned_date") plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id") if not group_id or not planned_date: raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids FROM training_groups WHERE id = %s """, (group_id,), ) group = cur.fetchone() if not group: raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden") role = tenant.global_role co_trainers = group["co_trainer_ids"] or [] if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen") if role not in ["admin", "superadmin"]: if group["trainer_id"] != profile_id and profile_id not in co_trainers: raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe") tpl_id_safe = None if plan_template_id: _template_access(cur, plan_template_id, profile_id, role) tpl_id_safe = plan_template_id cur.execute( """ INSERT INTO training_units ( group_id, planned_date, planned_time_start, planned_time_end, status, created_by, plan_template_id ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( group_id, planned_date, group["time_start"], group["time_end"], "planned", profile_id, tpl_id_safe, ), ) unit_id = cur.fetchone()["id"] if tpl_id_safe: _instantiate_from_template(cur, unit_id, tpl_id_safe) _promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role) conn.commit() return get_training_unit(unit_id, tenant)