""" Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek). Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`): Liste/Detail über `library_content_visibility_sql`; Bearbeiten/Löschen wie Übungen (s. club_tenancy). 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 ( assert_library_content_deletable, assert_library_content_editable, assert_library_content_governance_transition, 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) assert_library_content_editable(cur, profile_id, role, row_prev) 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 if merged_vis == "club" and merged_club is None: merged_club = tenant.effective_club_id if "visibility" in data or "club_id" in data: assert_library_content_governance_transition( cur, profile_id, role, row_prev, merged_vis, merged_club ) 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) assert_library_content_deletable(cur, profile_id, role, row_del) cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,)) conn.commit() return {"ok": True}