""" 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 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, is_platform_admin, library_content_visible_to_profile, ) 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 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: raise HTTPException( status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen" ) 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, tg.trainer_id, tg.co_trainer_ids, 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_trainers = unit_row["co_trainer_ids"] or [] if role not in ["admin", "superadmin"]: if ( unit_row["created_by"] != profile_id and unit_row["trainer_id"] != profile_id and profile_id not in co_trainers and unit_row.get("lead_trainer_profile_id") != profile_id ): raise HTTPException(status_code=403, detail="Keine Berechtigung") def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None: if role not in ["admin", "superadmin"] and created_by != profile_id: 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: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer.""" if role in ("admin", "superadmin"): 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, ) -> Optional[int]: """NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext.""" 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") if role in ("admin", "superadmin"): return nid if nid == profile_id: return nid cur.execute( "SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s", (group_id,), ) gr = cur.fetchone() if not gr: raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden") eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set() for x in gr.get("co_trainer_ids") or []: eligible.add(x) if nid in eligible: return nid raise HTTPException( status_code=403, detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein", ) # 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 _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 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 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": items_clean.append( { "item_type": "note", "order_index": oix, "note_body": it.get("note_body") or "", } ) continue if itype != "exercise" or not it.get("exercise_id"): continue items_clean.append( { "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"), } ) 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 _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 = "" 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 ) VALUES (%s, %s, 'note', NULL, NULL, NULL, NULL, NULL, NULL, %s ) """, (section_id, order_ix, body), ) 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) 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 ) VALUES (%s, %s, 'exercise', %s, %s, %s, %s, %s, %s, NULL ) """, ( section_id, order_ix, eid, vid, raw.get("planned_duration_min"), raw.get("actual_duration_min"), raw.get("notes"), raw.get("modifications"), ), ) 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 _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) if is_platform_admin(role): cur.execute( """ SELECT t.*, (SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id) AS sections_count FROM training_plan_templates t ORDER BY t.updated_at DESC NULLS LAST, t.name """ ) else: 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), 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 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") cob = gr["co_trainer_ids"] or [] if gr["trainer_id"] != profile_id and profile_id not in cob: 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, 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 = [] if role not in ["admin", "superadmin"]: where.append( "(tu.created_by = %s OR tg.trainer_id = %s OR " "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))" ) params.extend([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 " "(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> 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 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/{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, COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id, 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") 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( """ 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 ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( 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, ), ) 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) 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: lead_sql = "" lead_params: List[Any] = [] 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, ) lead_sql = ", lead_trainer_profile_id = %s" lead_params.append(nl) 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} 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) + (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 []) 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 created_by, framework_slot_id FROM training_units WHERE 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(role, unit["created_by"], profile_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, ) 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) conn.commit() return get_training_unit(unit_id, tenant)