2026-05-12 21:35:07 +02:00
|
|
|
|
"""
|
|
|
|
|
|
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
|
|
|
|
|
|
|
|
|
|
|
Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`):
|
2026-05-16 10:53:00 +02:00
|
|
|
|
Liste/Detail über `library_content_visibility_sql`; Bearbeiten/Löschen wie Übungen (s. club_tenancy).
|
2026-05-12 21:35:07 +02:00
|
|
|
|
|
|
|
|
|
|
Siehe `.claude/docs/working/TRAINING_MODULES_IMPLEMENTATION_PLAN.md`.
|
|
|
|
|
|
"""
|
|
|
|
|
|
from typing import Any, Dict, List, Optional, Tuple
|
|
|
|
|
|
|
|
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
|
|
|
|
|
|
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
|
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
|
|
|
|
|
from club_tenancy import (
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_deletable,
|
|
|
|
|
|
assert_library_content_editable,
|
|
|
|
|
|
assert_library_content_governance_transition,
|
2026-05-12 21:35:07 +02:00
|
|
|
|
assert_valid_governance_visibility,
|
|
|
|
|
|
is_platform_admin,
|
|
|
|
|
|
library_content_visible_to_profile,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
router = APIRouter(prefix="/api", tags=["training_modules"])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _has_planning_role(role: Optional[str]) -> bool:
|
|
|
|
|
|
return role in ("admin", "superadmin", "trainer", "user")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _fetch_training_module_row(cur, mid: int) -> Dict[str, Any]:
|
|
|
|
|
|
cur.execute("SELECT * FROM training_modules WHERE id = %s", (mid,))
|
|
|
|
|
|
r = cur.fetchone()
|
|
|
|
|
|
if not r:
|
|
|
|
|
|
raise HTTPException(status_code=404, detail="Trainingsmodul nicht gefunden")
|
|
|
|
|
|
return r2d(r)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _module_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 "club",
|
|
|
|
|
|
row.get("club_id"),
|
|
|
|
|
|
row.get("created_by"),
|
|
|
|
|
|
role,
|
|
|
|
|
|
):
|
|
|
|
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Modul")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _module_access(cur, mid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
|
|
|
|
|
row = _fetch_training_module_row(cur, mid)
|
|
|
|
|
|
_module_assert_readable(cur, row, profile_id, role)
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]):
|
|
|
|
|
|
if not variant_id:
|
|
|
|
|
|
return
|
|
|
|
|
|
if not exercise_id:
|
|
|
|
|
|
raise HTTPException(
|
|
|
|
|
|
status_code=400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt"
|
|
|
|
|
|
)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s",
|
|
|
|
|
|
(variant_id, exercise_id),
|
|
|
|
|
|
)
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Variante passt nicht zur gewählten Übung")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _optional_positive_int(val, field_name: str) -> Optional[int]:
|
|
|
|
|
|
if val is None or val == "":
|
|
|
|
|
|
return None
|
|
|
|
|
|
try:
|
|
|
|
|
|
i = int(val)
|
|
|
|
|
|
except (TypeError, ValueError):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
|
|
|
|
|
|
if i < 1:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail=f"Ungültige {field_name}")
|
|
|
|
|
|
return i
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _replace_module_items(cur, module_id: int, items_in: Optional[List[Any]]) -> None:
|
|
|
|
|
|
cur.execute("DELETE FROM training_module_items WHERE module_id = %s", (module_id,))
|
|
|
|
|
|
items_in = items_in or []
|
|
|
|
|
|
for i, raw in enumerate(items_in):
|
|
|
|
|
|
itype = raw.get("item_type")
|
|
|
|
|
|
if not itype:
|
|
|
|
|
|
itype = "exercise" if raw.get("exercise_id") else "note"
|
|
|
|
|
|
order_ix = raw.get("order_index")
|
|
|
|
|
|
if order_ix is None:
|
|
|
|
|
|
order_ix = i
|
|
|
|
|
|
order_ix = int(order_ix)
|
|
|
|
|
|
|
|
|
|
|
|
if itype == "note":
|
|
|
|
|
|
body = raw.get("note_body")
|
|
|
|
|
|
if body is None:
|
|
|
|
|
|
body = ""
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO training_module_items (
|
|
|
|
|
|
module_id, order_index, item_type,
|
|
|
|
|
|
exercise_id, exercise_variant_id,
|
|
|
|
|
|
planned_duration_min, notes, note_body
|
|
|
|
|
|
) VALUES (%s, %s, 'note',
|
|
|
|
|
|
NULL, NULL, NULL, NULL, %s)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(module_id, order_ix, body),
|
|
|
|
|
|
)
|
|
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
|
|
|
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)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO training_module_items (
|
|
|
|
|
|
module_id, order_index, item_type,
|
|
|
|
|
|
exercise_id, exercise_variant_id,
|
|
|
|
|
|
planned_duration_min, notes, note_body
|
|
|
|
|
|
) VALUES (%s, %s, 'exercise',
|
|
|
|
|
|
%s, %s, %s, %s, NULL)
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
module_id,
|
|
|
|
|
|
order_ix,
|
|
|
|
|
|
eid,
|
|
|
|
|
|
vid,
|
|
|
|
|
|
raw.get("planned_duration_min"),
|
|
|
|
|
|
raw.get("notes"),
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def load_training_module_for_apply(
|
|
|
|
|
|
cur, module_id: int, profile_id: int, role: Optional[str]
|
|
|
|
|
|
) -> Tuple[List[Dict[str, Any]], int]:
|
|
|
|
|
|
"""
|
|
|
|
|
|
Liest Modul inkl. Items für Übernahme in eine Trainingseinheit.
|
|
|
|
|
|
Returns (items_ordered, module_id).
|
|
|
|
|
|
Raises HTTPException bei 403/404.
|
|
|
|
|
|
"""
|
|
|
|
|
|
row = _fetch_training_module_row(cur, module_id)
|
|
|
|
|
|
_module_assert_readable(cur, row, profile_id, role or "")
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT item_type, exercise_id, exercise_variant_id,
|
|
|
|
|
|
planned_duration_min, notes, note_body
|
|
|
|
|
|
FROM training_module_items
|
|
|
|
|
|
WHERE module_id = %s
|
|
|
|
|
|
ORDER BY order_index ASC
|
|
|
|
|
|
""",
|
|
|
|
|
|
(module_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
raw_items = [r2d(x) for x in cur.fetchall()]
|
|
|
|
|
|
items: List[Dict[str, Any]] = []
|
|
|
|
|
|
for r in raw_items:
|
|
|
|
|
|
items.append(dict(r))
|
|
|
|
|
|
return items, int(module_id)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/training-modules")
|
|
|
|
|
|
def list_training_modules(tenant: TenantContext = Depends(get_tenant_context)):
|
|
|
|
|
|
profile_id = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
vis_clause, vis_params = library_content_visibility_sql(
|
|
|
|
|
|
alias="m",
|
|
|
|
|
|
profile_id=profile_id,
|
|
|
|
|
|
role=role,
|
|
|
|
|
|
effective_club_id=tenant.effective_club_id,
|
|
|
|
|
|
)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"""
|
|
|
|
|
|
SELECT m.*,
|
|
|
|
|
|
(SELECT COUNT(*) FROM training_module_items i WHERE i.module_id = m.id)
|
|
|
|
|
|
AS items_count
|
|
|
|
|
|
FROM training_modules m
|
|
|
|
|
|
WHERE ({vis_clause})
|
|
|
|
|
|
ORDER BY m.updated_at DESC NULLS LAST, m.title
|
|
|
|
|
|
""",
|
|
|
|
|
|
vis_params,
|
|
|
|
|
|
)
|
|
|
|
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.get("/training-modules/{module_id}")
|
|
|
|
|
|
def get_training_module(module_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 = _module_access(cur, module_id, profile_id, role)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
SELECT *
|
|
|
|
|
|
FROM training_module_items
|
|
|
|
|
|
WHERE module_id = %s
|
|
|
|
|
|
ORDER BY order_index ASC
|
|
|
|
|
|
""",
|
|
|
|
|
|
(module_id,),
|
|
|
|
|
|
)
|
|
|
|
|
|
row["items"] = [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
return row
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.post("/training-modules")
|
|
|
|
|
|
def create_training_module(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 Trainer dürfen Trainingsmodule anlegen")
|
|
|
|
|
|
|
|
|
|
|
|
title = (data.get("title") or "").strip()
|
|
|
|
|
|
if not title:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
|
|
|
|
|
|
|
|
|
|
|
vis_raw = data.get("visibility")
|
|
|
|
|
|
visibility = (vis_raw if isinstance(vis_raw, str) else "club").strip() or "club"
|
|
|
|
|
|
club_id = data.get("club_id")
|
|
|
|
|
|
if club_id in ("", []):
|
|
|
|
|
|
club_id = None
|
|
|
|
|
|
if visibility == "club" and club_id is None:
|
|
|
|
|
|
club_id = tenant.effective_club_id
|
|
|
|
|
|
|
|
|
|
|
|
primary_method_id = data.get("primary_method_id")
|
|
|
|
|
|
if primary_method_id in ("", []):
|
|
|
|
|
|
primary_method_id = None
|
|
|
|
|
|
if primary_method_id is not None:
|
|
|
|
|
|
primary_method_id = int(primary_method_id)
|
|
|
|
|
|
|
|
|
|
|
|
items_in = data.get("items") or []
|
|
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
|
|
|
|
|
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
|
|
|
|
|
|
if primary_method_id is not None:
|
|
|
|
|
|
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (primary_method_id,))
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
|
|
|
|
|
|
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
"""
|
|
|
|
|
|
INSERT INTO training_modules (
|
|
|
|
|
|
club_id, created_by, title, summary, goal,
|
|
|
|
|
|
recommended_duration_min, target_group_notes, deployment_context_notes,
|
|
|
|
|
|
primary_method_id, visibility
|
|
|
|
|
|
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
|
|
|
|
|
RETURNING id
|
|
|
|
|
|
""",
|
|
|
|
|
|
(
|
|
|
|
|
|
club_id,
|
|
|
|
|
|
profile_id,
|
|
|
|
|
|
title,
|
|
|
|
|
|
(data.get("summary") or "").strip() or None,
|
|
|
|
|
|
data.get("goal"),
|
|
|
|
|
|
data.get("recommended_duration_min"),
|
|
|
|
|
|
data.get("target_group_notes"),
|
|
|
|
|
|
data.get("deployment_context_notes"),
|
|
|
|
|
|
primary_method_id,
|
|
|
|
|
|
visibility,
|
|
|
|
|
|
),
|
|
|
|
|
|
)
|
|
|
|
|
|
mid = cur.fetchone()["id"]
|
|
|
|
|
|
_replace_module_items(cur, mid, items_in if isinstance(items_in, list) else [])
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return get_training_module(mid, tenant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.put("/training-modules/{module_id}")
|
|
|
|
|
|
def update_training_module(
|
|
|
|
|
|
module_id: int, data: dict, 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_prev = _fetch_training_module_row(cur, module_id)
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_editable(cur, profile_id, role, row_prev)
|
2026-05-12 21:35:07 +02:00
|
|
|
|
|
|
|
|
|
|
merged_vis = row_prev.get("visibility") or "club"
|
|
|
|
|
|
merged_club = row_prev.get("club_id")
|
|
|
|
|
|
|
|
|
|
|
|
if "visibility" in data:
|
|
|
|
|
|
v_in = data.get("visibility")
|
|
|
|
|
|
if not isinstance(v_in, str) or v_in not in ("private", "club", "official"):
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="visibility ungültig")
|
|
|
|
|
|
merged_vis = v_in
|
|
|
|
|
|
|
|
|
|
|
|
if "club_id" in data:
|
|
|
|
|
|
merged_club = data.get("club_id")
|
|
|
|
|
|
if merged_club in ("", []):
|
|
|
|
|
|
merged_club = None
|
2026-05-16 10:53:00 +02:00
|
|
|
|
if merged_vis == "club" and merged_club is None:
|
|
|
|
|
|
merged_club = tenant.effective_club_id
|
2026-05-12 21:35:07 +02:00
|
|
|
|
if "visibility" in data or "club_id" in data:
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_governance_transition(
|
|
|
|
|
|
cur, profile_id, role, row_prev, merged_vis, merged_club
|
|
|
|
|
|
)
|
2026-05-12 21:35:07 +02:00
|
|
|
|
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
|
|
|
|
|
|
|
|
|
|
|
fields: List[str] = []
|
|
|
|
|
|
params: List[Any] = []
|
|
|
|
|
|
|
|
|
|
|
|
if "title" in data:
|
|
|
|
|
|
t = data.get("title")
|
|
|
|
|
|
t = t.strip() if isinstance(t, str) else ""
|
|
|
|
|
|
if not t:
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
|
|
|
|
|
fields.append("title = %s")
|
|
|
|
|
|
params.append(t)
|
|
|
|
|
|
|
|
|
|
|
|
for col in ("summary", "goal", "target_group_notes", "deployment_context_notes"):
|
|
|
|
|
|
if col in data:
|
|
|
|
|
|
fields.append(f"{col} = %s")
|
|
|
|
|
|
v = data.get(col)
|
|
|
|
|
|
if col == "summary" and isinstance(v, str):
|
|
|
|
|
|
v = v.strip() or None
|
|
|
|
|
|
params.append(v)
|
|
|
|
|
|
|
|
|
|
|
|
if "recommended_duration_min" in data:
|
|
|
|
|
|
fields.append("recommended_duration_min = %s")
|
|
|
|
|
|
params.append(data.get("recommended_duration_min"))
|
|
|
|
|
|
|
|
|
|
|
|
if "primary_method_id" in data:
|
|
|
|
|
|
pm = data.get("primary_method_id")
|
|
|
|
|
|
if pm in ("", [], None):
|
|
|
|
|
|
fields.append("primary_method_id = %s")
|
|
|
|
|
|
params.append(None)
|
|
|
|
|
|
else:
|
|
|
|
|
|
pm = int(pm)
|
|
|
|
|
|
cur.execute("SELECT 1 FROM training_methods WHERE id = %s", (pm,))
|
|
|
|
|
|
if not cur.fetchone():
|
|
|
|
|
|
raise HTTPException(status_code=400, detail="Trainingsmethode nicht gefunden")
|
|
|
|
|
|
fields.append("primary_method_id = %s")
|
|
|
|
|
|
params.append(pm)
|
|
|
|
|
|
|
|
|
|
|
|
if "club_id" in data:
|
|
|
|
|
|
fields.append("club_id = %s")
|
|
|
|
|
|
params.append(merged_club)
|
|
|
|
|
|
|
|
|
|
|
|
if "visibility" in data:
|
|
|
|
|
|
fields.append("visibility = %s")
|
|
|
|
|
|
params.append(merged_vis)
|
|
|
|
|
|
|
|
|
|
|
|
if fields:
|
|
|
|
|
|
fields.append("updated_at = NOW()")
|
|
|
|
|
|
params.append(module_id)
|
|
|
|
|
|
cur.execute(
|
|
|
|
|
|
f"UPDATE training_modules SET {', '.join(fields)} WHERE id = %s",
|
|
|
|
|
|
tuple(params),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
if "items" in data:
|
|
|
|
|
|
items_in = data["items"]
|
|
|
|
|
|
_replace_module_items(cur, module_id, items_in if isinstance(items_in, list) else [])
|
|
|
|
|
|
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
|
|
|
|
|
return get_training_module(module_id, tenant)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@router.delete("/training-modules/{module_id}")
|
|
|
|
|
|
def delete_training_module(module_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_del = _fetch_training_module_row(cur, module_id)
|
2026-05-16 10:53:00 +02:00
|
|
|
|
assert_library_content_deletable(cur, profile_id, role, row_del)
|
2026-05-12 21:35:07 +02:00
|
|
|
|
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
|
|
|
|
|
|
conn.commit()
|
|
|
|
|
|
return {"ok": True}
|