shinkan-jinkendo/backend/routers/training_framework_programs.py
Lars c919e02441
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 40s
feat: enhance tenant context integration and update access layer endpoints
- Implemented `library_content_visibility_sql` for managing visibility of exercises, training planning, and framework programs based on tenant context.
- Updated access layer documentation to reflect changes in endpoint visibility and governance requirements.
- Bumped application version to 0.8.23 in both backend and frontend files.
- Enhanced changelog to document the new version and changes made in this release.
2026-05-05 21:46:41 +02:00

578 lines
21 KiB
Python

"""
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); Schreiben nur Ersteller oder Plattform-Admin.
"""
from typing import Any, Dict, List, Optional, Sequence
from fastapi import APIRouter, Depends, HTTPException
from club_tenancy import (
assert_valid_governance_visibility,
exercise_visible_to_profile,
is_platform_admin,
)
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_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 exercise_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_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="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["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["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,
) -> 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"]
sections_in = slot.get("sections")
exercises_in = slot.get("exercises")
if 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
"""
if is_platform_admin(role):
cur.execute(base_sel + " ORDER BY fp.updated_at DESC NULLS LAST, fp.title")
else:
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)
_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)
_framework_assert_writable(cur, row_prev, profile_id, role)
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 "visibility" in data or "club_id" in data:
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)
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)
_framework_assert_writable(cur, row_fw, profile_id, role)
cur.execute(
"DELETE FROM training_framework_programs WHERE id = %s",
(framework_id,),
)
conn.commit()
return {"ok": True}