shinkan-jinkendo/backend/routers/training_framework_programs.py
Lars b054c642a3
Some checks failed
Deploy Development / deploy (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 14s
Test Suite / playwright-tests (push) Failing after 40s
chore: update training framework specifications and versioning
- Incremented version to 0.8.8 and updated database schema version to 20260505035.
- Added new entity `training_framework_programs` to manage training frameworks, including goals and slots.
- Enhanced `training_plan_templates` with a visibility attribute and backfilled existing data.
- Updated API to support CRUD operations for training frameworks, ensuring proper authorization similar to existing planning libraries.
- Revised documentation in DOMAIN_MODEL.md, TRAINING_CURRICULUM_AND_GOVERNANCE_CONCEPT.md, and TRAINING_FRAMEWORK_SPEC.md to reflect these changes.
2026-05-05 08:41:43 +02:00

439 lines
16 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Trainingsrahmenprogramm — RahmenVorlage über mehrere SessionSlots (CURR002 Stufe 2).
AuthZ wie PlanungsVorlagen: 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}