All checks were successful
Deploy Development / deploy (push) Successful in 39s
Test Suite / pytest-backend (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 56s
- Added new API endpoints for managing training modules, including listing, creating, updating, and deleting modules. - Implemented the ability to apply training modules to training units, allowing users to copy module content into specific sections. - Enhanced the frontend with new pages for managing training modules and integrated modal functionality for applying modules within the training planning page. - Updated version to 0.8.97 and adjusted database schema version accordingly. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
382 lines
13 KiB
Python
382 lines
13 KiB
Python
"""
|
||
Trainingsmodule — wiederverwendbare Planungsbausteine (Bibliothek).
|
||
|
||
Governance wie Trainings‑Mikrovorlagen (`training_plan_templates`):
|
||
Liste/Detail über `library_content_visibility_sql`; Schreiben: Ersteller oder Plattform‑Admin.
|
||
|
||
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_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_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="Nur der Ersteller darf dieses Modul ändern")
|
||
|
||
|
||
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)
|
||
_module_assert_writable(cur, row_prev, profile_id, role)
|
||
|
||
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 "visibility" in data or "club_id" in data:
|
||
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)
|
||
_module_assert_writable(cur, row_del, profile_id, role)
|
||
cur.execute("DELETE FROM training_modules WHERE id = %s", (module_id,))
|
||
conn.commit()
|
||
return {"ok": True}
|