""" Trainingsrahmenprogramm — Rahmen‑Vorlage über mehrere Session‑Slots (CURR‑002 Stufe 2). AuthZ wie Planungs‑Vorlagen: Admin sieht alle, sonst nur eigene Artefakte; Schreibzugriff mit Planungsrolle. """ from typing import Any, Dict, List, Optional from fastapi import APIRouter, Depends, HTTPException from auth import require_auth from db import get_db, get_cursor, r2d from routers.training_planning import ( _assert_training_unit_permission, _can_access_group_for_create, _has_planning_role, _optional_positive_int, _training_unit_guard_row, _validate_variant_for_exercise, ) router = APIRouter(prefix="/api", tags=["training_framework_programs"]) _VALID_PLAN_MODE = frozenset({"concrete", "library"}) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> 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") row = r2d(r) if role in ("admin", "superadmin"): return row if row.get("created_by") != profile_id: raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen") return row def _fetch_slot_exercises(cur, slot_id: int) -> List[Dict[str, Any]]: cur.execute( """ SELECT t.id, t.slot_id, t.exercise_id, t.exercise_variant_id, t.order_index, e.title AS exercise_title, ev.variant_name AS exercise_variant_name FROM training_framework_slot_exercises t LEFT JOIN exercises e ON e.id = t.exercise_id LEFT JOIN exercise_variants ev ON ev.id = t.exercise_variant_id WHERE t.slot_id = %s ORDER BY t.order_index """, (slot_id,), ) return [r2d(x) for x 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, training_unit_id 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: s["exercises"] = _fetch_slot_exercises(cur, s["id"]) row["slots"] = slots 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 _assert_framework_invariants(plan_mode: str, group_id: Optional[int]) -> None: if plan_mode == "library" and group_id is not None: raise HTTPException( status_code=400, detail="plan_mode library erlaubt kein group_id", ) def _assert_slot_unit_constraints( cur, plan_mode: str, framework_group_id: Optional[int], training_unit_id: Optional[int], profile_id: int, role: str, ) -> None: if plan_mode == "library" and training_unit_id: raise HTTPException( status_code=400, detail="Im Bibliotheksmodus (library) keine Verknüpfung von Slots zu Trainingseinheiten", ) if not training_unit_id: return uid = training_unit_id unit_row = _training_unit_guard_row(cur, uid) _assert_training_unit_permission(cur, unit_row, profile_id, role) if framework_group_id is not None and unit_row["group_id"] != framework_group_id: raise HTTPException( status_code=400, detail="training_unit_id muss zur group_id dieses Rahmens gehören", ) 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_slots_and_exercises( cur, framework_id: int, plan_mode: str, framework_group_id: Optional[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 unit_sid = _optional_positive_int(slot.get("training_unit_id"), "training_unit_id") _assert_slot_unit_constraints(cur, plan_mode, framework_group_id, unit_sid, profile_id, role) cur.execute( """ INSERT INTO training_framework_slots ( framework_program_id, sort_order, title, notes, training_unit_id ) VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( framework_id, int(order_ix), title_s, slot.get("notes"), unit_sid, ), ) sid = cur.fetchone()["id"] ex_items = slot.get("exercises") or [] for ej, raw in enumerate(ex_items): 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) oidx = raw.get("order_index") if oidx is None: oidx = ej cur.execute( """ INSERT INTO training_framework_slot_exercises ( slot_id, exercise_id, exercise_variant_id, order_index ) VALUES (%s, %s, %s, %s) """, (sid, eid, vid, int(oidx)), ) @router.get("/training-framework-programs") def list_training_framework_programs(session=Depends(require_auth)): profile_id = session["profile_id"] role = session.get("role") with get_db() as conn: cur = get_cursor(conn) base_sel = """ SELECT fp.*, (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 FROM training_framework_programs fp """ if role in ("admin", "superadmin"): cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title") else: cur.execute( base_sel + " WHERE fp.created_by = %s ORDER BY fp.updated_at DESC NULLS LAST, fp.title", (profile_id,), ) return [r2d(r) for r in cur.fetchall()] @router.get("/training-framework-programs/{framework_id}") def get_training_framework_program(framework_id: int, session=Depends(require_auth)): profile_id = session["profile_id"] role = session.get("role") with get_db() as conn: cur = get_cursor(conn) row = _framework_access(cur, framework_id, profile_id, role) return _hydrate_framework(cur, row) @router.post("/training-framework-programs") def create_training_framework_program(data: dict, session=Depends(require_auth)): profile_id = session["profile_id"] role = session.get("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") plan_mode = (data.get("plan_mode") or "").strip().lower() if plan_mode not in _VALID_PLAN_MODE: raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein") gid = None if data.get("group_id") not in (None, ""): gid = _optional_positive_int(data.get("group_id"), "group_id") _assert_framework_invariants(plan_mode, gid) vis = data.get("visibility") or "private" vis = _assert_visibility(vis) club_id = data.get("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") with get_db() as conn: cur = get_cursor(conn) if gid is not None: _can_access_group_for_create(cur, gid, profile_id, role) cur.execute( """ INSERT INTO training_framework_programs ( title, description, plan_mode, group_id, planned_period_start, planned_period_end, visibility, club_id, created_by ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( title[:200], data.get("description"), plan_mode, gid, data.get("planned_period_start"), data.get("planned_period_end"), vis, club_id, profile_id, ), ) fid = cur.fetchone()["id"] _insert_goal_rows(cur, fid, goals_in) _insert_slots_and_exercises(cur, fid, plan_mode, gid, slots_in, profile_id, role) conn.commit() return get_training_framework_program(fid, session) @router.put("/training-framework-programs/{framework_id}") def update_training_framework_program(framework_id: int, data: dict, session=Depends(require_auth)): profile_id = session["profile_id"] role = session.get("role") if not _has_planning_role(role): raise HTTPException(status_code=403, detail="Keine Berechtigung") with get_db() as conn: cur = get_cursor(conn) existing = _framework_access(cur, framework_id, profile_id, role) plan_mode_new = existing["plan_mode"] if "plan_mode" in data: pm = (data.get("plan_mode") or "").strip().lower() if pm not in _VALID_PLAN_MODE: raise HTTPException(status_code=400, detail="plan_mode muss concrete oder library sein") plan_mode_new = pm group_id_eff = existing.get("group_id") if "group_id" in data: if data.get("group_id") in (None, ""): group_id_eff = None else: group_id_eff = _optional_positive_int(data.get("group_id"), "group_id") _assert_framework_invariants(plan_mode_new, group_id_eff) 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 "plan_mode" in data: header_fields.append("plan_mode = %s") header_params.append(plan_mode_new) if "group_id" in data: header_fields.append("group_id = %s") header_params.append(group_id_eff) 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: v = _assert_visibility(data.get("visibility")) if v is None: raise HTTPException(status_code=400, detail="visibility fehlt") header_fields.append("visibility = %s") header_params.append(v) if "club_id" in data: header_fields.append("club_id = %s") header_params.append(data.get("club_id")) if group_id_eff is not None and ( ("group_id" in data) or (plan_mode_new == "concrete" and plan_mode_new != existing.get("plan_mode")) ): _can_access_group_for_create(cur, group_id_eff, profile_id, role) 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 "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_exercises( cur, framework_id, plan_mode_new, group_id_eff, data.get("slots") or [], profile_id, role, ) if plan_mode_new == "library": cur.execute( """ UPDATE training_framework_slots SET training_unit_id = NULL WHERE framework_program_id = %s AND training_unit_id IS NOT NULL """, (framework_id,), ) if "goals" in data or "slots" in data or header_fields: cur.execute( "UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s", (framework_id,), ) conn.commit() return get_training_framework_program(framework_id, session) @router.delete("/training-framework-programs/{framework_id}") def delete_training_framework_program(framework_id: int, session=Depends(require_auth)): profile_id = session["profile_id"] role = session.get("role") with get_db() as conn: cur = get_cursor(conn) _framework_access(cur, framework_id, profile_id, role) cur.execute( "DELETE FROM training_framework_programs WHERE id = %s", (framework_id,), ) conn.commit() return {"ok": True}