439 lines
16 KiB
Python
439 lines
16 KiB
Python
|
|
"""
|
|||
|
|
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}
|