""" Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots. Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage), nicht über group_id oder training_unit_id am Rahmen. Lesen wie Übungen (official / private / club); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy). """ from typing import Any, Dict, List, Optional, Sequence from fastapi import APIRouter, Depends, HTTPException from club_tenancy import ( assert_library_content_deletable, assert_library_content_editable, assert_library_content_governance_transition, assert_valid_governance_visibility, is_platform_admin, library_content_visible_to_profile, ) from db import get_db, get_cursor, r2d from routers.training_planning import ( _has_planning_role, _hydrate_training_unit_payload, _optional_positive_int, _insert_sections_from_legacy_exercises, _replace_unit_phases, _replace_unit_sections, _validate_variant_for_exercise, ) from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql router = APIRouter(prefix="/api", tags=["training_framework_programs"]) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]: cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,)) r = cur.fetchone() if not r: raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden") return r2d(r) def _framework_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 "private", row.get("club_id"), row.get("created_by"), role, ): raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: row = _fetch_framework_row(cur, framework_id) _framework_assert_readable(cur, row, profile_id, role) return row def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]: """Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife).""" with get_db() as conn: cur = get_cursor(conn) row = _framework_access(cur, framework_id, profile_id, role) return _hydrate_framework(cur, row) def _training_type_ids(cur, framework_id: int) -> List[int]: cur.execute( """ SELECT training_type_id FROM training_framework_program_training_types WHERE framework_program_id = %s ORDER BY training_type_id """, (framework_id,), ) return [r["training_type_id"] for r in cur.fetchall()] def _target_group_ids(cur, framework_id: int) -> List[int]: cur.execute( """ SELECT target_group_id FROM training_framework_program_target_groups WHERE framework_program_id = %s ORDER BY target_group_id """, (framework_id,), ) return [r["target_group_id"] for r in cur.fetchall()] def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]: fid = row["id"] cur.execute( """ SELECT id, framework_program_id, sort_order, title, notes FROM training_framework_goals WHERE framework_program_id = %s ORDER BY sort_order """, (fid,), ) row["goals"] = [r2d(g) for g in cur.fetchall()] cur.execute( """ SELECT id, framework_program_id, sort_order, title, notes FROM training_framework_slots WHERE framework_program_id = %s ORDER BY sort_order """, (fid,), ) slots = [r2d(s) for s in cur.fetchall()] for s in slots: cur.execute( "SELECT id FROM training_units WHERE framework_slot_id = %s", (s["id"],), ) row_b = cur.fetchone() if not row_b: s["blueprint_training_unit_id"] = None s["phases"] = [] s["sections"] = [] s["exercises"] = [] continue uid = row_b["id"] s["blueprint_training_unit_id"] = uid unit_min: Dict[str, Any] = {"id": uid} _hydrate_training_unit_payload(cur, unit_min) s["phases"] = unit_min.get("phases", []) s["sections"] = unit_min.get("sections", []) s["exercises"] = unit_min.get("exercises", []) row["slots"] = slots row["training_type_ids"] = _training_type_ids(cur, fid) row["target_group_ids"] = _target_group_ids(cur, fid) return row def _assert_visibility(val: Optional[str]) -> Optional[str]: if val is None: return None if val not in _VALID_VISIBILITY: raise HTTPException( status_code=400, detail="visibility muss private, club oder official sein", ) return val def _parse_positive_int_ids(raw: Any, label: str) -> List[int]: if raw is None: return [] if not isinstance(raw, list): raise HTTPException(status_code=400, detail=f"{label} muss eine Liste von IDs sein") out: List[int] = [] for item in raw: if item in (None, ""): continue try: n = int(item) except (TypeError, ValueError): raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") from None if n <= 0: raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") if n not in out: out.append(n) return out def _replace_training_types(cur, framework_id: int, ids: Sequence[int]) -> None: cur.execute( "DELETE FROM training_framework_program_training_types WHERE framework_program_id = %s", (framework_id,), ) for tid in ids: cur.execute( """ INSERT INTO training_framework_program_training_types (framework_program_id, training_type_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (framework_id, tid), ) def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None: cur.execute( "DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s", (framework_id,), ) for gid in ids: cur.execute( """ INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id) VALUES (%s, %s) ON CONFLICT DO NOTHING """, (framework_id, gid), ) def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None: if not goals_in: raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich") for gi, g in enumerate(goals_in): title_g = (g.get("title") or "").strip() if not title_g: raise HTTPException(status_code=400, detail="Jedes Ziel braucht ein nicht-leeres title") order_ix = g.get("sort_order") if order_ix is None: order_ix = gi cur.execute( """ INSERT INTO training_framework_goals ( framework_program_id, sort_order, title, notes ) VALUES (%s, %s, %s, %s) """, (framework_id, int(order_ix), title_g[:500], g.get("notes")), ) def _insert_default_blueprint_section(cur, blueprint_unit_id: int) -> None: """Leerer Ablauf, falls noch keine Sektionen existieren.""" cur.execute( "SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s", (blueprint_unit_id,), ) if cur.fetchone(): return _replace_unit_sections( cur, blueprint_unit_id, [{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}], ) def _insert_slots_and_blueprints( cur, framework_id: int, slots_in: Optional[List[Any]], profile_id: int, role: str, ) -> None: if slots_in is None: return for si, slot in enumerate(slots_in): order_ix = slot.get("sort_order") if order_ix is None: order_ix = si title_s = slot.get("title") if title_s is not None: title_s = title_s.strip() or None cur.execute( """ INSERT INTO training_framework_slots ( framework_program_id, sort_order, title, notes, training_unit_id ) VALUES (%s, %s, %s, %s, NULL) RETURNING id """, ( framework_id, int(order_ix), title_s, slot.get("notes"), ), ) sid = cur.fetchone()["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, framework_slot_id ) VALUES ( NULL, NULL, NULL, NULL, NULL, 'planned', NULL, NULL, %s, NULL, %s ) RETURNING id """, (profile_id, sid), ) bid = cur.fetchone()["id"] phases_in = slot.get("phases") sections_in = slot.get("sections") exercises_in = slot.get("exercises") if phases_in is not None and isinstance(phases_in, list) and len(phases_in) > 0: _replace_unit_phases(cur, bid, phases_in, profile_id, role, profile_id) elif sections_in is not None: if len(sections_in) == 0: _insert_default_blueprint_section(cur, bid) else: _replace_unit_sections(cur, bid, sections_in) elif exercises_in is not None and len(exercises_in) > 0: for raw in exercises_in: eid = raw.get("exercise_id") if not eid: continue vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id") _validate_variant_for_exercise(cur, int(eid), vid) _insert_sections_from_legacy_exercises(cur, bid, exercises_in) else: _insert_default_blueprint_section(cur, bid) @router.get("/training-framework-programs") def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)): profile_id = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) base_sel = """ SELECT fp.*, fa.name AS focus_area_name, sd.name AS style_direction_name, (SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id) AS goals_count, (SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id) AS slots_count, (SELECT COUNT(*)::int FROM training_framework_program_training_types t WHERE t.framework_program_id = fp.id) AS training_types_count, (SELECT COUNT(*)::int FROM training_framework_program_target_groups tg WHERE tg.framework_program_id = fp.id) AS target_groups_count, ( SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name) FROM training_framework_program_training_types j JOIN training_types typ ON typ.id = j.training_type_id WHERE j.framework_program_id = fp.id ) AS training_type_names_agg, ( SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name) FROM training_framework_program_target_groups j JOIN target_groups tg ON tg.id = j.target_group_id WHERE j.framework_program_id = fp.id ) AS target_group_names_agg FROM training_framework_programs fp LEFT JOIN focus_areas fa ON fa.id = fp.focus_area_id LEFT JOIN style_directions sd ON sd.id = fp.style_direction_id """ vis_clause, vis_params = library_content_visibility_sql( alias="fp", profile_id=profile_id, role=role, effective_club_id=tenant.effective_club_id, ) cur.execute( base_sel + f""" WHERE ({vis_clause}) ORDER BY fp.updated_at DESC NULLS LAST, fp.title""", vis_params, ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-framework-programs/{framework_id}") def get_training_framework_program( framework_id: int, tenant: TenantContext = Depends(get_tenant_context) ): profile_id = tenant.profile_id role = tenant.global_role return _response_framework_detail(framework_id, profile_id, role) @router.post("/training-framework-programs") def create_training_framework_program( 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 Planungsberechtigte dürfen Rahmenprogramme anlegen") title = (data.get("title") or "").strip() if not title: raise HTTPException(status_code=400, detail="title ist Pflicht") vis = data.get("visibility") or "private" vis = _assert_visibility(vis) club_id = data.get("club_id") if club_id in ("", []): club_id = None if vis == "club" and club_id is None: club_id = tenant.effective_club_id goals_in = data.get("goals") slots_in = data.get("slots") if not isinstance(goals_in, list) or not goals_in: raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht") fa_id = _optional_positive_int(data.get("focus_area_id"), "focus_area_id") sd_id = _optional_positive_int(data.get("style_direction_id"), "style_direction_id") tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids") tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids") with get_db() as conn: cur = get_cursor(conn) assert_valid_governance_visibility(cur, profile_id, role, vis, club_id) cur.execute( """ INSERT INTO training_framework_programs ( title, description, planned_period_start, planned_period_end, visibility, club_id, created_by, focus_area_id, style_direction_id ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( title[:200], data.get("description"), data.get("planned_period_start"), data.get("planned_period_end"), vis, club_id, profile_id, fa_id, sd_id, ), ) fid = cur.fetchone()["id"] _insert_goal_rows(cur, fid, goals_in) _insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role) _replace_training_types(cur, fid, tt_ids) _replace_target_groups(cur, fid, tg_ids) conn.commit() return _response_framework_detail(fid, profile_id, role) @router.put("/training-framework-programs/{framework_id}") def update_training_framework_program( framework_id: int, 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="Keine Berechtigung") with get_db() as conn: cur = get_cursor(conn) row_prev = _fetch_framework_row(cur, framework_id) assert_library_content_editable(cur, profile_id, role, row_prev) merged_vis = row_prev.get("visibility") or "private" merged_club = row_prev.get("club_id") if "visibility" in data: v_m = _assert_visibility(data.get("visibility")) if v_m is None: raise HTTPException(status_code=400, detail="visibility fehlt") merged_vis = v_m if "club_id" in data: merged_club = data.get("club_id") if merged_club in ("", []): merged_club = None if merged_vis == "club" and merged_club is None: merged_club = tenant.effective_club_id if "visibility" in data or "club_id" in data: assert_library_content_governance_transition( cur, profile_id, role, row_prev, merged_vis, merged_club ) assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club) header_fields = [] header_params: List[Any] = [] if "title" in data: tit = (data.get("title") or "").strip() if not tit: raise HTTPException(status_code=400, detail="title ist Pflicht") header_fields.append("title = %s") header_params.append(tit[:200]) if "description" in data: header_fields.append("description = %s") header_params.append(data.get("description")) if "planned_period_start" in data: header_fields.append("planned_period_start = %s") header_params.append(data.get("planned_period_start")) if "planned_period_end" in data: header_fields.append("planned_period_end = %s") header_params.append(data.get("planned_period_end")) if "visibility" in data: header_fields.append("visibility = %s") header_params.append(merged_vis) if "club_id" in data: header_fields.append("club_id = %s") header_params.append(merged_club) if "focus_area_id" in data: fidv = data.get("focus_area_id") header_fields.append("focus_area_id = %s") header_params.append( None if fidv in (None, "") else _optional_positive_int(fidv, "focus_area_id") ) if "style_direction_id" in data: sidv = data.get("style_direction_id") header_fields.append("style_direction_id = %s") header_params.append( None if sidv in (None, "") else _optional_positive_int(sidv, "style_direction_id") ) if header_fields: header_fields.append("updated_at = NOW()") header_params.append(framework_id) cur.execute( f""" UPDATE training_framework_programs SET {", ".join(header_fields)} WHERE id = %s """, tuple(header_params), ) if "training_type_ids" in data: tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids") _replace_training_types(cur, framework_id, tt_ids) if "target_group_ids" in data: tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids") _replace_target_groups(cur, framework_id, tg_ids) if "goals" in data: goals_in = data["goals"] if not isinstance(goals_in, list) or not goals_in: raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht") cur.execute( "DELETE FROM training_framework_goals WHERE framework_program_id = %s", (framework_id,), ) _insert_goal_rows(cur, framework_id, goals_in) if "slots" in data: cur.execute( "DELETE FROM training_framework_slots WHERE framework_program_id = %s", (framework_id,), ) _insert_slots_and_blueprints( cur, framework_id, data.get("slots") or [], profile_id, role ) if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data: cur.execute( "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", (framework_id,), ) conn.commit() return _response_framework_detail(framework_id, profile_id, role) @router.delete("/training-framework-programs/{framework_id}") def delete_training_framework_program( framework_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_fw = _fetch_framework_row(cur, framework_id) assert_library_content_deletable(cur, profile_id, role, row_fw) cur.execute( "DELETE FROM training_framework_programs WHERE id = %s", (framework_id,), ) conn.commit() return {"ok": True}