All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 6s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
- Introduced a new filter option for listing training units to show only those with pending debriefs. - Updated the dashboard to reflect changes in training unit statuses, renaming components for clarity. - Enhanced the Training Planning Page to manage debrief completion status, including UI elements for user interaction. - Improved API utility to support new filtering criteria for training units, ensuring accurate data retrieval.
1652 lines
59 KiB
Python
1652 lines
59 KiB
Python
"""
|
||
Training Planning – Trainingseinheiten, strukturierter Ablauf (Sektionen + Übungen/Notizen)
|
||
und wiederverwendbare Trainingsvorlagen (Sektions-Gliederung).
|
||
|
||
Governance: Sichtbarkeit wie Übungen (private / club / official); Schreiben nur Ersteller oder Plattform-Admin.
|
||
"""
|
||
from typing import Any, Dict, List, Optional
|
||
|
||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||
|
||
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,
|
||
can_manage_club_org,
|
||
is_platform_admin,
|
||
library_content_visible_to_profile,
|
||
)
|
||
|
||
router = APIRouter(prefix="/api", tags=["training_planning"])
|
||
|
||
|
||
def _has_planning_role(role: Optional[str]) -> bool:
|
||
"""Kann Trainingseinheiten/Vorlagen anlegen (bis Governance: auch einfacher Account)."""
|
||
return role in ("admin", "superadmin", "trainer", "user")
|
||
|
||
|
||
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 _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 _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str) -> None:
|
||
cur.execute(
|
||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||
(group_id,),
|
||
)
|
||
group = cur.fetchone()
|
||
if not group:
|
||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||
co_trainers = group["co_trainer_ids"] or []
|
||
if not _has_planning_role(role):
|
||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
||
if role not in ["admin", "superadmin"]:
|
||
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
|
||
if not can_manage_club_org(cur, profile_id, int(group["club_id"]), role):
|
||
raise HTTPException(
|
||
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
|
||
)
|
||
|
||
|
||
def _profile_active_in_club(cur, club_id: int, profile_id: int) -> bool:
|
||
cur.execute(
|
||
"""
|
||
SELECT 1 FROM club_members
|
||
WHERE club_id = %s AND profile_id = %s AND status = 'active'
|
||
LIMIT 1
|
||
""",
|
||
(club_id, profile_id),
|
||
)
|
||
return cur.fetchone() is not None
|
||
|
||
|
||
def _caller_may_assign_session_trainers(
|
||
cur,
|
||
group_row: Dict[str, Any],
|
||
profile_id: int,
|
||
role: str,
|
||
unit_created_by: Optional[int],
|
||
) -> bool:
|
||
if is_platform_admin(role):
|
||
return True
|
||
cid = group_row.get("club_id")
|
||
if cid is not None and can_manage_club_org(cur, profile_id, int(cid), role):
|
||
return True
|
||
if unit_created_by is not None and unit_created_by == profile_id:
|
||
return True
|
||
if group_row.get("trainer_id") == profile_id:
|
||
return True
|
||
co = group_row.get("co_trainer_ids") or []
|
||
return profile_id in co
|
||
|
||
|
||
def _effective_co_trainer_ids_for_row(unit_row: Dict[str, Any]) -> List[int]:
|
||
"""Leseregel: Session-Co-Trainer überschreiben die Gruppe; NULL auf der Einheit = Gruppen-Standard."""
|
||
unit_asst = unit_row.get("assistant_trainer_profile_ids")
|
||
if unit_asst is not None:
|
||
src = unit_asst
|
||
else:
|
||
src = unit_row.get("co_trainer_ids") or []
|
||
seen: set = set()
|
||
out: List[int] = []
|
||
for x in src:
|
||
try:
|
||
i = int(x)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if i not in seen:
|
||
seen.add(i)
|
||
out.append(i)
|
||
return sorted(out)
|
||
|
||
|
||
def effective_co_trainer_profile_ids_for_merge(
|
||
unit_assistant: Any, group_co: Any
|
||
) -> List[int]:
|
||
"""Reine Hilfsfunktion (pytest): gleiche Semantik wie _effective_co_trainer_ids_for_row."""
|
||
if unit_assistant is not None:
|
||
src = unit_assistant
|
||
else:
|
||
src = group_co or []
|
||
seen: set = set()
|
||
out: List[int] = []
|
||
for x in src:
|
||
try:
|
||
i = int(x)
|
||
except (TypeError, ValueError):
|
||
continue
|
||
if i not in seen:
|
||
seen.add(i)
|
||
out.append(i)
|
||
return sorted(out)
|
||
|
||
|
||
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||
cur.execute(
|
||
"""
|
||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
|
||
tu.lead_trainer_profile_id,
|
||
tu.assistant_trainer_profile_ids,
|
||
tg.trainer_id, tg.co_trainer_ids, tg.club_id AS group_club_id,
|
||
fwp.created_by AS framework_created_by
|
||
FROM training_units tu
|
||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||
LEFT JOIN training_framework_slots fs ON fs.id = tu.framework_slot_id
|
||
LEFT JOIN training_framework_programs fwp ON fwp.id = fs.framework_program_id
|
||
WHERE tu.id = %s
|
||
""",
|
||
(unit_id,),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||
return r2d(row)
|
||
|
||
|
||
def _assert_training_unit_permission(
|
||
cur, unit_row: Dict[str, Any], profile_id: int, role: str
|
||
) -> None:
|
||
if unit_row.get("framework_slot_id"):
|
||
if role in ["admin", "superadmin"]:
|
||
return
|
||
if unit_row.get("created_by") == profile_id:
|
||
return
|
||
fw_by = unit_row.get("framework_created_by")
|
||
if fw_by is not None and fw_by == profile_id:
|
||
return
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||
|
||
co_eff = _effective_co_trainer_ids_for_row(unit_row)
|
||
if role in ["admin", "superadmin"]:
|
||
return
|
||
gcid = unit_row.get("group_club_id")
|
||
if gcid is not None and can_manage_club_org(cur, profile_id, int(gcid), role):
|
||
return
|
||
if (
|
||
unit_row["created_by"] != profile_id
|
||
and unit_row["trainer_id"] != profile_id
|
||
and profile_id not in co_eff
|
||
and unit_row.get("lead_trainer_profile_id") != profile_id
|
||
):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||
|
||
|
||
def _assert_delete_training_unit(
|
||
cur,
|
||
role: str,
|
||
created_by: int,
|
||
profile_id: int,
|
||
group_club_id: Optional[int],
|
||
) -> None:
|
||
if role in ["admin", "superadmin"]:
|
||
return
|
||
if created_by == profile_id:
|
||
return
|
||
if group_club_id is not None and can_manage_club_org(cur, profile_id, int(group_club_id), role):
|
||
return
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||
|
||
|
||
def _assert_club_visible_for_trainer(cur, club_id: int, profile_id: int, role: str) -> None:
|
||
"""Nicht-Admin: Vereinsbezug für Listen mit club_id (Mitglied genügt; Details filtert WHERE)."""
|
||
if role in ("admin", "superadmin"):
|
||
return
|
||
if can_manage_club_org(cur, profile_id, club_id, role):
|
||
return
|
||
cur.execute(
|
||
"""
|
||
SELECT 1 FROM club_members
|
||
WHERE club_id = %s AND profile_id = %s AND status = 'active'
|
||
LIMIT 1
|
||
""",
|
||
(club_id, profile_id),
|
||
)
|
||
if cur.fetchone():
|
||
return
|
||
cur.execute(
|
||
"""
|
||
SELECT 1 FROM training_groups g
|
||
WHERE g.club_id = %s AND g.status = 'active'
|
||
AND (
|
||
g.trainer_id = %s
|
||
OR (g.co_trainer_ids IS NOT NULL AND g.co_trainer_ids @> jsonb_build_array(%s::int))
|
||
)
|
||
LIMIT 1
|
||
""",
|
||
(club_id, profile_id, profile_id),
|
||
)
|
||
if not cur.fetchone():
|
||
raise HTTPException(status_code=403, detail="Kein Zugriff auf diesen Verein")
|
||
|
||
|
||
def _normalize_lead_trainer_profile_id(
|
||
cur,
|
||
group_id: int,
|
||
raw_lead: Any,
|
||
profile_id: int,
|
||
role: str,
|
||
unit_created_by: Optional[int],
|
||
) -> Optional[int]:
|
||
"""NULL = Standard (Gruppen-Haupttrainer); sonst gültiges Profil i. d. R. mit Vereinsbezug."""
|
||
if raw_lead is None:
|
||
return None
|
||
if raw_lead in ("", []):
|
||
return None
|
||
try:
|
||
nid = int(raw_lead)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
|
||
if nid < 1:
|
||
raise HTTPException(status_code=400, detail="lead_trainer_profile_id ungültig")
|
||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||
if not cur.fetchone():
|
||
raise HTTPException(status_code=400, detail="Profil für Leitung nicht gefunden")
|
||
|
||
cur.execute(
|
||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||
(group_id,),
|
||
)
|
||
gr = cur.fetchone()
|
||
if not gr:
|
||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||
grd = dict(gr)
|
||
cid = grd.get("club_id")
|
||
if cid is None:
|
||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||
club_i = int(cid)
|
||
|
||
if is_platform_admin(role):
|
||
return nid
|
||
|
||
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||
for x in grd.get("co_trainer_ids") or []:
|
||
try:
|
||
eligible.add(int(x))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
if nid == profile_id:
|
||
if not _profile_active_in_club(cur, club_i, profile_id):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Nur aktive Vereinsmitglieder können die Leitung dieser Einheit übernehmen",
|
||
)
|
||
return nid
|
||
|
||
if nid not in eligible:
|
||
if not _profile_active_in_club(cur, club_i, nid):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Leitung nur für Profile mit aktiver Mitgliedschaft im Verein der Gruppe",
|
||
)
|
||
if not _caller_may_assign_session_trainers(cur, grd, profile_id, role, unit_created_by):
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Keine Berechtigung, die Leitung zuzuweisen",
|
||
)
|
||
return nid
|
||
|
||
if nid != profile_id and not _caller_may_assign_session_trainers(
|
||
cur, grd, profile_id, role, unit_created_by
|
||
):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung, die Leitung anderen zuzuweisen")
|
||
return nid
|
||
|
||
|
||
def _normalize_assistant_trainer_profile_ids(
|
||
cur,
|
||
group_id: int,
|
||
raw_val: Any,
|
||
profile_id: int,
|
||
role: str,
|
||
unit_created_by: Optional[int],
|
||
lead_nid: Optional[int],
|
||
) -> Any:
|
||
"""
|
||
None = Vererbung aus training_groups.co_trainer_ids (SQL NULL);
|
||
Liste = Session-Co-Trainer (JSONB Array; leeres Array ausdrücklich ohne Co.)
|
||
"""
|
||
if raw_val is None:
|
||
return None
|
||
if not isinstance(raw_val, list):
|
||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids muss Liste oder null sein")
|
||
|
||
ids_in: List[int] = []
|
||
for x in raw_val:
|
||
try:
|
||
i = int(x)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||
if i < 1:
|
||
raise HTTPException(status_code=400, detail="assistant_trainer_profile_ids ungültig")
|
||
ids_in.append(i)
|
||
uniq = sorted(set(ids_in))
|
||
|
||
cur.execute(
|
||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s",
|
||
(group_id,),
|
||
)
|
||
gr = cur.fetchone()
|
||
if not gr:
|
||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||
grd = dict(gr)
|
||
cid = grd.get("club_id")
|
||
if cid is None:
|
||
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
|
||
club_i = int(cid)
|
||
|
||
if not is_platform_admin(role) and not _caller_may_assign_session_trainers(
|
||
cur, grd, profile_id, role, unit_created_by
|
||
):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung, Co-Trainer zuzuweisen")
|
||
|
||
eligible = {grd["trainer_id"]} if grd.get("trainer_id") else set()
|
||
for x in grd.get("co_trainer_ids") or []:
|
||
try:
|
||
eligible.add(int(x))
|
||
except (TypeError, ValueError):
|
||
continue
|
||
|
||
eff_lead = lead_nid if lead_nid is not None else (grd.get("trainer_id") or None)
|
||
|
||
for nid in uniq:
|
||
cur.execute("SELECT 1 FROM profiles WHERE id = %s", (nid,))
|
||
if not cur.fetchone():
|
||
raise HTTPException(status_code=400, detail="Profil für Co-Trainer nicht gefunden")
|
||
if eff_lead is not None and nid == eff_lead:
|
||
raise HTTPException(status_code=400, detail="Leitung und Co-Trainer dürfen sich nicht überschneiden")
|
||
if is_platform_admin(role):
|
||
continue
|
||
if nid in eligible:
|
||
continue
|
||
if not _profile_active_in_club(cur, club_i, nid):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Co-Trainer nur mit aktiver Mitgliedschaft im Verein dieser Gruppe",
|
||
)
|
||
return uniq
|
||
|
||
# Nachverfolgbarkeit: Übernahmen aus Rahmenprogramm über origin_framework_slot_id
|
||
_ORIGIN_LINEAGE_JOIN = """
|
||
LEFT JOIN training_framework_slots origin_slot ON origin_slot.id = tu.origin_framework_slot_id
|
||
LEFT JOIN training_framework_programs origin_fp ON origin_fp.id = origin_slot.framework_program_id
|
||
"""
|
||
_ORIGIN_LINEAGE_FIELDS = """
|
||
origin_fp.id AS origin_framework_program_id,
|
||
origin_fp.title AS origin_framework_program_title,
|
||
COALESCE(TRIM(origin_slot.title), '') AS origin_framework_slot_title,
|
||
origin_slot.sort_order AS origin_framework_slot_sort_order
|
||
"""
|
||
|
||
|
||
def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||
cur.execute(
|
||
"""
|
||
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||
FROM training_unit_sections
|
||
WHERE training_unit_id = %s
|
||
ORDER BY order_index
|
||
""",
|
||
(unit_id,),
|
||
)
|
||
secs = []
|
||
for sec_row in cur.fetchall():
|
||
sec = r2d(sec_row)
|
||
cur.execute(
|
||
"""
|
||
SELECT tusi.*,
|
||
e.title AS exercise_title,
|
||
e.summary AS exercise_summary,
|
||
(
|
||
SELECT fa.name FROM exercise_focus_areas efa
|
||
JOIN focus_areas fa ON fa.id = efa.focus_area_id
|
||
WHERE efa.exercise_id = e.id
|
||
ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC
|
||
LIMIT 1
|
||
) AS exercise_focus_area,
|
||
ev.variant_name AS exercise_variant_name
|
||
FROM training_unit_section_items tusi
|
||
LEFT JOIN exercises e ON tusi.exercise_id = e.id
|
||
LEFT JOIN exercise_variants ev ON tusi.exercise_variant_id = ev.id
|
||
WHERE tusi.section_id = %s
|
||
ORDER BY tusi.order_index
|
||
""",
|
||
(sec["id"],),
|
||
)
|
||
sec["items"] = [r2d(r) for r in cur.fetchall()]
|
||
secs.append(sec)
|
||
return secs
|
||
|
||
|
||
def _sections_clone_payload(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||
"""Sektionen/Items für eine tiefe Kopie (ohne DB-IDs / Join-Felder)."""
|
||
secs = _fetch_sections(cur, unit_id)
|
||
out: List[Dict[str, Any]] = []
|
||
for sec in secs:
|
||
items_clean: List[Dict[str, Any]] = []
|
||
for it in sorted(sec.get("items", []), key=lambda x: x.get("order_index", 0)):
|
||
itype = it.get("item_type") or ("exercise" if it.get("exercise_id") else "note")
|
||
oix = it.get("order_index")
|
||
if itype == "note":
|
||
items_clean.append(
|
||
{
|
||
"item_type": "note",
|
||
"order_index": oix,
|
||
"note_body": it.get("note_body") or "",
|
||
}
|
||
)
|
||
continue
|
||
if itype != "exercise" or not it.get("exercise_id"):
|
||
continue
|
||
items_clean.append(
|
||
{
|
||
"item_type": "exercise",
|
||
"order_index": oix,
|
||
"exercise_id": it["exercise_id"],
|
||
"exercise_variant_id": it.get("exercise_variant_id"),
|
||
"planned_duration_min": it.get("planned_duration_min"),
|
||
"actual_duration_min": it.get("actual_duration_min"),
|
||
"notes": it.get("notes"),
|
||
"modifications": it.get("modifications"),
|
||
}
|
||
)
|
||
out.append(
|
||
{
|
||
"title": sec.get("title"),
|
||
"order_index": sec.get("order_index"),
|
||
"guidance_notes": sec.get("guidance_notes"),
|
||
"items": items_clean,
|
||
}
|
||
)
|
||
return out
|
||
|
||
|
||
def _copy_blueprint_into_scheduled_unit(
|
||
cur,
|
||
blueprint_unit_id: int,
|
||
group_id: int,
|
||
planned_date: str,
|
||
profile_id: int,
|
||
origin_framework_slot_id: Optional[int],
|
||
) -> int:
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_units (
|
||
group_id,
|
||
planned_date,
|
||
planned_time_start,
|
||
planned_time_end,
|
||
planned_focus,
|
||
actual_date,
|
||
actual_time_start,
|
||
actual_time_end,
|
||
attendance_count,
|
||
status,
|
||
notes,
|
||
trainer_notes,
|
||
created_by,
|
||
plan_template_id,
|
||
origin_framework_slot_id,
|
||
framework_slot_id
|
||
)
|
||
SELECT
|
||
%s,
|
||
%s,
|
||
planned_time_start,
|
||
planned_time_end,
|
||
planned_focus,
|
||
NULL::DATE,
|
||
NULL::TIME WITHOUT TIME ZONE,
|
||
NULL::TIME WITHOUT TIME ZONE,
|
||
NULL::INT,
|
||
COALESCE(status, 'planned'),
|
||
notes,
|
||
trainer_notes,
|
||
%s,
|
||
NULL::INT,
|
||
%s,
|
||
NULL::INT
|
||
FROM training_units
|
||
WHERE id = %s
|
||
AND framework_slot_id IS NOT NULL
|
||
RETURNING id
|
||
""",
|
||
(
|
||
group_id,
|
||
planned_date,
|
||
profile_id,
|
||
origin_framework_slot_id,
|
||
blueprint_unit_id,
|
||
),
|
||
)
|
||
row = cur.fetchone()
|
||
if not row:
|
||
raise HTTPException(status_code=404, detail="Blueprint-Einheit nicht gefunden")
|
||
nu = row["id"]
|
||
cloned = _sections_clone_payload(cur, blueprint_unit_id)
|
||
_replace_unit_sections(cur, nu, cloned)
|
||
return nu
|
||
|
||
|
||
def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
||
flat: List[Dict[str, Any]] = []
|
||
for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)):
|
||
for item in sorted(sec.get("items", []), key=lambda i: i.get("order_index", 0)):
|
||
if item.get("item_type") == "exercise":
|
||
flat.append(item)
|
||
unit["exercises"] = flat
|
||
|
||
|
||
def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
|
||
uid = unit["id"]
|
||
unit["sections"] = _fetch_sections(cur, uid)
|
||
_flatten_exercises_from_sections(unit)
|
||
return unit
|
||
|
||
|
||
def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], start_order: int = 0):
|
||
if items_in is None:
|
||
items_in = []
|
||
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 = start_order + i
|
||
if itype == "note":
|
||
body = raw.get("note_body")
|
||
if body is None:
|
||
body = ""
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_unit_section_items (
|
||
section_id, order_index, item_type,
|
||
exercise_id, exercise_variant_id,
|
||
planned_duration_min, actual_duration_min,
|
||
notes, modifications, note_body
|
||
) VALUES (%s, %s, 'note',
|
||
NULL, NULL, NULL, NULL, NULL, NULL, %s
|
||
)
|
||
""",
|
||
(section_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_unit_section_items (
|
||
section_id, order_index, item_type,
|
||
exercise_id, exercise_variant_id,
|
||
planned_duration_min, actual_duration_min,
|
||
notes, modifications, note_body
|
||
) VALUES (%s, %s, 'exercise',
|
||
%s, %s, %s, %s, %s, %s, NULL
|
||
)
|
||
""",
|
||
(
|
||
section_id,
|
||
order_ix,
|
||
eid,
|
||
vid,
|
||
raw.get("planned_duration_min"),
|
||
raw.get("actual_duration_min"),
|
||
raw.get("notes"),
|
||
raw.get("modifications"),
|
||
),
|
||
)
|
||
|
||
|
||
def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
|
||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||
for si, sec in enumerate(sections_in):
|
||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||
order_ix = sec.get("order_index")
|
||
if order_ix is None:
|
||
order_ix = si
|
||
src_tsec = _optional_positive_int(sec.get("source_template_section_id"), "source_template_section_id")
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_unit_sections (
|
||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||
) VALUES (%s, %s, %s, %s, %s)
|
||
RETURNING id
|
||
""",
|
||
(
|
||
unit_id,
|
||
order_ix,
|
||
title,
|
||
sec.get("guidance_notes"),
|
||
src_tsec,
|
||
),
|
||
)
|
||
sid = cur.fetchone()["id"]
|
||
_insert_section_items(cur, sid, sec.get("items"))
|
||
|
||
|
||
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
|
||
if not exercises_in:
|
||
return
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||
VALUES (%s, 0, %s, NULL)
|
||
RETURNING id
|
||
""",
|
||
(unit_id, "Übungen"),
|
||
)
|
||
sid = cur.fetchone()["id"]
|
||
slot = 0
|
||
filtered: List[Dict[str, Any]] = []
|
||
for ex in exercises_in:
|
||
eid = ex.get("exercise_id")
|
||
if not eid:
|
||
continue
|
||
eid = int(eid)
|
||
vid = _optional_positive_int(ex.get("exercise_variant_id"), "exercise_variant_id")
|
||
_validate_variant_for_exercise(cur, eid, vid)
|
||
filtered.append(
|
||
{
|
||
"item_type": "exercise",
|
||
"order_index": slot,
|
||
"exercise_id": eid,
|
||
"exercise_variant_id": vid,
|
||
"planned_duration_min": ex.get("planned_duration_min"),
|
||
"actual_duration_min": ex.get("actual_duration_min"),
|
||
"notes": ex.get("notes"),
|
||
"modifications": ex.get("modifications"),
|
||
}
|
||
)
|
||
slot += 1
|
||
_insert_section_items(cur, sid, filtered, start_order=0)
|
||
|
||
|
||
def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
||
cur.execute(
|
||
"""
|
||
SELECT id, title, guidance_text
|
||
FROM training_plan_template_sections
|
||
WHERE template_id = %s
|
||
ORDER BY order_index
|
||
""",
|
||
(template_id,),
|
||
)
|
||
rows = cur.fetchall()
|
||
for row in rows:
|
||
r = r2d(row)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_unit_sections (
|
||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||
) VALUES (%s, (
|
||
SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
|
||
WHERE u2.training_unit_id = %s
|
||
), %s, %s, %s)
|
||
""",
|
||
(unit_id, unit_id, r["title"], r["guidance_text"], r["id"]),
|
||
)
|
||
|
||
# Fallback: keine Sektionen in Vorlage → ein leerer Block
|
||
if not rows:
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||
SELECT %s, 0, %s, NULL
|
||
WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s)
|
||
""",
|
||
(unit_id, "Hauptteil", unit_id),
|
||
)
|
||
|
||
|
||
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
|
||
cur.execute("SELECT * FROM training_plan_templates WHERE id = %s", (tid,))
|
||
r = cur.fetchone()
|
||
if not r:
|
||
raise HTTPException(status_code=404, detail="Trainingsvorlage nicht gefunden")
|
||
return r2d(r)
|
||
|
||
|
||
def _template_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 diese Vorlage")
|
||
|
||
|
||
def _template_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 diese Vorlage ändern")
|
||
|
||
|
||
def _template_access(cur, tid: int, profile_id: int, role: str) -> Dict[str, Any]:
|
||
"""Lesender Zugriff (Liste der Vorlage für Einheit); Schreiben: _template_assert_writable."""
|
||
row = _fetch_training_plan_template_row(cur, tid)
|
||
_template_assert_readable(cur, row, profile_id, role)
|
||
return row
|
||
|
||
|
||
# ── Vorlagen ────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/training-plan-templates")
|
||
def list_training_plan_templates(tenant: TenantContext = Depends(get_tenant_context)):
|
||
profile_id = tenant.profile_id
|
||
role = tenant.global_role
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
if is_platform_admin(role):
|
||
cur.execute(
|
||
"""
|
||
SELECT t.*,
|
||
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
|
||
AS sections_count
|
||
FROM training_plan_templates t
|
||
ORDER BY t.updated_at DESC NULLS LAST, t.name
|
||
"""
|
||
)
|
||
else:
|
||
vis_clause, vis_params = library_content_visibility_sql(
|
||
alias="t",
|
||
profile_id=profile_id,
|
||
role=role,
|
||
effective_club_id=tenant.effective_club_id,
|
||
)
|
||
cur.execute(
|
||
f"""
|
||
SELECT t.*,
|
||
(SELECT COUNT(*) FROM training_plan_template_sections s WHERE s.template_id = t.id)
|
||
AS sections_count
|
||
FROM training_plan_templates t
|
||
WHERE ({vis_clause})
|
||
ORDER BY t.updated_at DESC NULLS LAST, t.name
|
||
""",
|
||
vis_params,
|
||
)
|
||
return [r2d(r) for r in cur.fetchall()]
|
||
|
||
|
||
@router.get("/training-plan-templates/{template_id}")
|
||
def get_training_plan_template(template_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 = _template_access(cur, template_id, profile_id, role)
|
||
cur.execute(
|
||
"""
|
||
SELECT *
|
||
FROM training_plan_template_sections
|
||
WHERE template_id = %s
|
||
ORDER BY order_index
|
||
""",
|
||
(template_id,),
|
||
)
|
||
row["sections"] = [r2d(r) for r in cur.fetchall()]
|
||
return row
|
||
|
||
|
||
@router.post("/training-plan-templates")
|
||
def create_training_plan_template(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 Vorlagen anlegen")
|
||
name = (data.get("name") or "").strip()
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="name 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
|
||
sections_in = data.get("sections") or []
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
assert_valid_governance_visibility(cur, profile_id, role, visibility, club_id)
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_plan_templates (club_id, created_by, name, description, visibility)
|
||
VALUES (%s, %s, %s, %s, %s)
|
||
RETURNING id
|
||
""",
|
||
(club_id, profile_id, name, data.get("description"), visibility),
|
||
)
|
||
tid = cur.fetchone()["id"]
|
||
for si, sec in enumerate(sections_in):
|
||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||
order_ix = sec.get("order_index")
|
||
if order_ix is None:
|
||
order_ix = si
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
||
VALUES (%s, %s, %s, %s)
|
||
""",
|
||
(tid, order_ix, title, sec.get("guidance_text")),
|
||
)
|
||
conn.commit()
|
||
return get_training_plan_template(tid, tenant)
|
||
|
||
|
||
@router.put("/training-plan-templates/{template_id}")
|
||
def update_training_plan_template(template_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_plan_template_row(cur, template_id)
|
||
_template_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 = []
|
||
params: List[Any] = []
|
||
if "name" in data:
|
||
name = data.get("name")
|
||
name = name.strip() if isinstance(name, str) else ""
|
||
if not name:
|
||
raise HTTPException(status_code=400, detail="name ist Pflicht")
|
||
fields.append("name = %s")
|
||
params.append(name)
|
||
if "description" in data:
|
||
fields.append("description = %s")
|
||
params.append(data.get("description"))
|
||
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)
|
||
fields.append("updated_at = NOW()")
|
||
params.append(template_id)
|
||
cur.execute(
|
||
f"""
|
||
UPDATE training_plan_templates SET {", ".join(fields)}
|
||
WHERE id = %s
|
||
""",
|
||
tuple(params),
|
||
)
|
||
if "sections" in data:
|
||
cur.execute(
|
||
"DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
|
||
)
|
||
sections_in = data["sections"] or []
|
||
for si, sec in enumerate(sections_in):
|
||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||
order_ix = sec.get("order_index")
|
||
if order_ix is None:
|
||
order_ix = si
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
||
VALUES (%s, %s, %s, %s)
|
||
""",
|
||
(template_id, order_ix, title, sec.get("guidance_text")),
|
||
)
|
||
conn.commit()
|
||
return get_training_plan_template(template_id, tenant)
|
||
|
||
|
||
@router.delete("/training-plan-templates/{template_id}")
|
||
def delete_training_plan_template(template_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_plan_template_row(cur, template_id)
|
||
_template_assert_writable(cur, row_del, profile_id, role)
|
||
cur.execute("DELETE FROM training_plan_templates WHERE id = %s", (template_id,))
|
||
conn.commit()
|
||
return {"ok": True}
|
||
|
||
|
||
# ── Einheiten ─────────────────────────────────────────────────────────────
|
||
|
||
|
||
@router.get("/training-units")
|
||
def list_training_units(
|
||
group_id: Optional[int] = Query(default=None),
|
||
club_id: Optional[int] = Query(default=None),
|
||
start_date: Optional[str] = Query(default=None),
|
||
end_date: Optional[str] = Query(default=None),
|
||
status: Optional[str] = Query(default=None),
|
||
assigned_to_me: bool = Query(default=False),
|
||
debrief_pending: bool = Query(
|
||
default=False,
|
||
description="Nur abgeschlossene Einheiten ohne gesetzte Rückschau (debrief_completed_at IS NULL)",
|
||
),
|
||
sort: str = Query(default="desc"),
|
||
limit: Optional[int] = Query(default=None),
|
||
tenant: TenantContext = Depends(get_tenant_context),
|
||
):
|
||
profile_id = tenant.profile_id
|
||
role = tenant.global_role
|
||
|
||
gid = _optional_positive_int(group_id, "group_id") if group_id else None
|
||
cid = _optional_positive_int(club_id, "club_id") if club_id else None
|
||
if gid and cid:
|
||
raise HTTPException(status_code=400, detail="Nur eines der Parameter group_id oder club_id angeben")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
if cid and role not in ["admin", "superadmin"]:
|
||
_assert_club_visible_for_trainer(cur, cid, profile_id, role)
|
||
|
||
if gid and role not in ["admin", "superadmin"]:
|
||
cur.execute(
|
||
"SELECT trainer_id, co_trainer_ids, club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||
(gid,),
|
||
)
|
||
gr = cur.fetchone()
|
||
if not gr:
|
||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||
gd = dict(gr)
|
||
cob = gd.get("co_trainer_ids") or []
|
||
ok_staff = gd.get("trainer_id") == profile_id or profile_id in cob
|
||
ok_org = can_manage_club_org(cur, profile_id, int(gd["club_id"]), role)
|
||
ok_member = _profile_active_in_club(cur, int(gd["club_id"]), profile_id)
|
||
if not (ok_staff or ok_org or ok_member):
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
|
||
|
||
order_dir = "ASC" if (sort or "").strip().lower() == "asc" else "DESC"
|
||
lim: Optional[int] = None
|
||
if limit is not None:
|
||
try:
|
||
lim = int(limit)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="limit ungültig")
|
||
if lim < 1:
|
||
raise HTTPException(status_code=400, detail="limit ungültig")
|
||
lim = min(lim, 250)
|
||
|
||
query = """
|
||
SELECT tu.*,
|
||
tg.name as group_name,
|
||
tg.weekday as group_weekday,
|
||
tg.club_id AS group_club_id,
|
||
c.name as club_name,
|
||
p.name as trainer_name,
|
||
p.name as creator_name,
|
||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
||
AS effective_assistant_trainer_profile_ids,
|
||
leadp.name AS lead_trainer_name
|
||
"""
|
||
query += "," + _ORIGIN_LINEAGE_FIELDS
|
||
query += """
|
||
FROM training_units tu
|
||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
|
||
"""
|
||
query += _ORIGIN_LINEAGE_JOIN
|
||
|
||
where = []
|
||
params = []
|
||
|
||
skip_involvement_filter = role in ("admin", "superadmin")
|
||
if not skip_involvement_filter and cid is not None:
|
||
if can_manage_club_org(cur, profile_id, cid, role):
|
||
skip_involvement_filter = True
|
||
if not skip_involvement_filter and gid is not None:
|
||
cur.execute(
|
||
"SELECT club_id FROM training_groups WHERE id = %s AND status = 'active'",
|
||
(gid,),
|
||
)
|
||
gcx = cur.fetchone()
|
||
if gcx and gcx.get("club_id") is not None:
|
||
if can_manage_club_org(cur, profile_id, int(gcx["club_id"]), role):
|
||
skip_involvement_filter = True
|
||
|
||
if not skip_involvement_filter:
|
||
where.append(
|
||
"(tu.created_by = %s OR tg.trainer_id = %s OR tu.lead_trainer_profile_id = %s OR "
|
||
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||
"@> jsonb_build_array(%s::int))"
|
||
)
|
||
params.extend([profile_id, profile_id, profile_id, profile_id])
|
||
|
||
where.append("tu.framework_slot_id IS NULL")
|
||
|
||
if gid:
|
||
where.append("tu.group_id = %s")
|
||
params.append(gid)
|
||
|
||
if cid:
|
||
where.append("tg.club_id = %s")
|
||
params.append(cid)
|
||
|
||
if assigned_to_me:
|
||
where.append(
|
||
"(COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) = %s OR "
|
||
"COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb) "
|
||
"@> jsonb_build_array(%s::int))"
|
||
)
|
||
params.extend([profile_id, profile_id])
|
||
|
||
if start_date:
|
||
where.append("tu.planned_date >= %s")
|
||
params.append(start_date)
|
||
|
||
if end_date:
|
||
where.append("tu.planned_date <= %s")
|
||
params.append(end_date)
|
||
|
||
if debrief_pending:
|
||
where.append("tu.status = %s")
|
||
params.append("completed")
|
||
where.append("tu.debrief_completed_at IS NULL")
|
||
elif status:
|
||
where.append("tu.status = %s")
|
||
params.append(status)
|
||
|
||
if where:
|
||
query += " WHERE " + " AND ".join(where)
|
||
|
||
query += f" ORDER BY tu.planned_date {order_dir}, tu.planned_time_start {order_dir} NULLS LAST"
|
||
if lim is not None:
|
||
query += " LIMIT %s"
|
||
params.append(lim)
|
||
|
||
cur.execute(query, params)
|
||
rows = cur.fetchall()
|
||
return [r2d(r) for r in rows]
|
||
|
||
|
||
@router.get("/training-units/{unit_id}")
|
||
def get_training_unit(unit_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)
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT tu.*,
|
||
tg.name as group_name,
|
||
tg.weekday as group_weekday,
|
||
tg.time_start as group_time_start,
|
||
tg.time_end as group_time_end,
|
||
tg.location as group_location,
|
||
c.name as club_name,
|
||
p.name as trainer_name,
|
||
p.name as creator_name,
|
||
tg.trainer_id AS trainer_id,
|
||
tg.co_trainer_ids AS co_trainer_ids,
|
||
tg.club_id AS group_club_id,
|
||
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
|
||
COALESCE(tu.assistant_trainer_profile_ids, tg.co_trainer_ids, '[]'::jsonb)
|
||
AS effective_assistant_trainer_profile_ids,
|
||
leadp.name AS lead_trainer_name,
|
||
""" + _ORIGIN_LINEAGE_FIELDS.strip() + """
|
||
FROM training_units tu
|
||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||
LEFT JOIN clubs c ON tg.club_id = c.id
|
||
LEFT JOIN profiles p ON tu.created_by = p.id
|
||
LEFT JOIN profiles leadp ON leadp.id = COALESCE(tu.lead_trainer_profile_id, tg.trainer_id)
|
||
""" + _ORIGIN_LINEAGE_JOIN.strip() + """
|
||
WHERE tu.id = %s
|
||
""",
|
||
(unit_id,),
|
||
)
|
||
|
||
unit = cur.fetchone()
|
||
if not unit:
|
||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||
|
||
unit = r2d(unit)
|
||
|
||
if unit.get("framework_slot_id"):
|
||
if role not in ["admin", "superadmin"]:
|
||
cur.execute(
|
||
"""
|
||
SELECT fp.created_by FROM training_framework_slots s
|
||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||
WHERE s.id = %s
|
||
""",
|
||
(unit["framework_slot_id"],),
|
||
)
|
||
fr = cur.fetchone()
|
||
cb = fr["created_by"] if fr else None
|
||
if unit["created_by"] != profile_id and cb != profile_id:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||
else:
|
||
if not unit.get("group_id"):
|
||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||
_assert_training_unit_permission(cur, unit, profile_id, role)
|
||
|
||
_hydrate_training_unit_payload(cur, unit)
|
||
return unit
|
||
|
||
|
||
@router.post("/training-units")
|
||
def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||
profile_id = tenant.profile_id
|
||
role = tenant.global_role
|
||
|
||
group_id = data.get("group_id")
|
||
planned_date = data.get("planned_date")
|
||
|
||
if not group_id or not planned_date:
|
||
raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")
|
||
|
||
plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
_can_access_group_for_create(cur, group_id, profile_id, role)
|
||
|
||
tpl_id_safe = None
|
||
if plan_template_id:
|
||
_template_access(cur, plan_template_id, profile_id, role)
|
||
tpl_id_safe = plan_template_id
|
||
|
||
cur.execute(
|
||
"SELECT trainer_id FROM training_groups WHERE id = %s",
|
||
(int(group_id),),
|
||
)
|
||
g0 = cur.fetchone()
|
||
default_group_trainer = g0["trainer_id"] if g0 else None
|
||
|
||
lead_ins: Optional[int] = None
|
||
if "lead_trainer_profile_id" in data:
|
||
lead_ins = _normalize_lead_trainer_profile_id(
|
||
cur,
|
||
int(group_id),
|
||
data.get("lead_trainer_profile_id"),
|
||
profile_id,
|
||
role,
|
||
profile_id,
|
||
)
|
||
assistant_val: Any = None
|
||
assistant_set = False
|
||
if "assistant_trainer_profile_ids" in data:
|
||
assistant_set = True
|
||
eff_lead_for_co = lead_ins if lead_ins is not None else default_group_trainer
|
||
assistant_val = _normalize_assistant_trainer_profile_ids(
|
||
cur,
|
||
int(group_id),
|
||
data.get("assistant_trainer_profile_ids"),
|
||
profile_id,
|
||
role,
|
||
profile_id,
|
||
eff_lead_for_co,
|
||
)
|
||
|
||
base_params = (
|
||
group_id,
|
||
planned_date,
|
||
data.get("planned_time_start"),
|
||
data.get("planned_time_end"),
|
||
data.get("planned_focus"),
|
||
data.get("status", "planned"),
|
||
data.get("notes"),
|
||
data.get("trainer_notes"),
|
||
profile_id,
|
||
tpl_id_safe,
|
||
lead_ins,
|
||
)
|
||
if assistant_set:
|
||
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,
|
||
lead_trainer_profile_id,
|
||
assistant_trainer_profile_ids
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
RETURNING id
|
||
""",
|
||
base_params + (assistant_val,),
|
||
)
|
||
else:
|
||
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,
|
||
lead_trainer_profile_id
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
|
||
RETURNING id
|
||
""",
|
||
base_params,
|
||
)
|
||
|
||
unit_id = cur.fetchone()["id"]
|
||
|
||
sections_in = data.get("sections")
|
||
exercises_in = data.get("exercises")
|
||
|
||
if sections_in is not None:
|
||
_replace_unit_sections(cur, unit_id, sections_in)
|
||
elif tpl_id_safe:
|
||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||
elif exercises_in is not None:
|
||
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
||
|
||
conn.commit()
|
||
|
||
return get_training_unit(unit_id, tenant)
|
||
|
||
|
||
@router.put("/training-units/{unit_id}")
|
||
def update_training_unit(unit_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)
|
||
|
||
unit_row = _training_unit_guard_row(cur, unit_id)
|
||
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
||
|
||
is_blueprint = unit_row.get("framework_slot_id") is not None
|
||
|
||
tpl_upd = data.get("plan_template_id") if "plan_template_id" in data else None
|
||
tpl_id_val = None
|
||
if tpl_upd not in (None, ""):
|
||
tid = _optional_positive_int(tpl_upd, "plan_template_id")
|
||
if tid:
|
||
_template_access(cur, tid, profile_id, role)
|
||
tpl_id_val = tid
|
||
|
||
trainer_notes_val = None
|
||
if "trainer_notes" not in data:
|
||
cur.execute(
|
||
"SELECT trainer_notes FROM training_units WHERE id = %s",
|
||
(unit_id,),
|
||
)
|
||
row_tn = cur.fetchone()
|
||
trainer_notes_val = row_tn["trainer_notes"] if row_tn else None
|
||
else:
|
||
trainer_notes_val = data.get("trainer_notes")
|
||
|
||
if is_blueprint:
|
||
if data.get("reset_from_template"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden",
|
||
)
|
||
if tpl_upd not in (None, ""):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig",
|
||
)
|
||
blueprint_fields = []
|
||
blueprint_params: List[Any] = []
|
||
if "planned_focus" in data:
|
||
blueprint_fields.append("planned_focus = %s")
|
||
blueprint_params.append(data.get("planned_focus"))
|
||
if "planned_time_start" in data:
|
||
blueprint_fields.append("planned_time_start = %s")
|
||
blueprint_params.append(data.get("planned_time_start"))
|
||
if "planned_time_end" in data:
|
||
blueprint_fields.append("planned_time_end = %s")
|
||
blueprint_params.append(data.get("planned_time_end"))
|
||
if "notes" in data:
|
||
blueprint_fields.append("notes = %s")
|
||
blueprint_params.append(data.get("notes"))
|
||
blueprint_fields.append("trainer_notes = %s")
|
||
blueprint_params.append(trainer_notes_val)
|
||
blueprint_params.append(unit_id)
|
||
cur.execute(
|
||
f"""
|
||
UPDATE training_units SET
|
||
{", ".join(blueprint_fields)},
|
||
updated_at = NOW()
|
||
WHERE id = %s
|
||
""",
|
||
tuple(blueprint_params),
|
||
)
|
||
else:
|
||
cur_lead = unit_row.get("lead_trainer_profile_id")
|
||
base_tr = unit_row.get("trainer_id")
|
||
lead_sql = ""
|
||
lead_params: List[Any] = []
|
||
assist_sql = ""
|
||
assist_params: List[Any] = []
|
||
nl: Optional[int]
|
||
if "lead_trainer_profile_id" in data:
|
||
nl = _normalize_lead_trainer_profile_id(
|
||
cur,
|
||
unit_row["group_id"],
|
||
data.get("lead_trainer_profile_id"),
|
||
profile_id,
|
||
role,
|
||
unit_row.get("created_by"),
|
||
)
|
||
lead_sql = ", lead_trainer_profile_id = %s"
|
||
lead_params.append(nl)
|
||
eff_lead_for_co = nl if nl is not None else base_tr
|
||
else:
|
||
nl = cur_lead if cur_lead is not None else base_tr
|
||
eff_lead_for_co = nl
|
||
|
||
if "assistant_trainer_profile_ids" in data:
|
||
na = _normalize_assistant_trainer_profile_ids(
|
||
cur,
|
||
unit_row["group_id"],
|
||
data.get("assistant_trainer_profile_ids"),
|
||
profile_id,
|
||
role,
|
||
unit_row.get("created_by"),
|
||
eff_lead_for_co,
|
||
)
|
||
assist_sql = ", assistant_trainer_profile_ids = %s"
|
||
assist_params.append(na)
|
||
|
||
debrief_frag = ""
|
||
if "debrief_completed" in data and not is_blueprint:
|
||
if data.get("debrief_completed") is True:
|
||
debrief_frag = ", debrief_completed_at = NOW()"
|
||
else:
|
||
debrief_frag = ", debrief_completed_at = NULL"
|
||
|
||
cur.execute(
|
||
f"""
|
||
UPDATE training_units SET
|
||
planned_date = COALESCE(%s, planned_date),
|
||
planned_time_start = %s,
|
||
planned_time_end = %s,
|
||
planned_focus = %s,
|
||
actual_date = %s,
|
||
actual_time_start = %s,
|
||
actual_time_end = %s,
|
||
attendance_count = %s,
|
||
status = %s,
|
||
notes = %s,
|
||
trainer_notes = %s,
|
||
plan_template_id = COALESCE(%s, plan_template_id),
|
||
updated_at = NOW()
|
||
{lead_sql}
|
||
{assist_sql}
|
||
{debrief_frag}
|
||
WHERE id = %s
|
||
""",
|
||
(
|
||
data.get("planned_date"),
|
||
data.get("planned_time_start"),
|
||
data.get("planned_time_end"),
|
||
data.get("planned_focus"),
|
||
data.get("actual_date"),
|
||
data.get("actual_time_start"),
|
||
data.get("actual_time_end"),
|
||
data.get("attendance_count"),
|
||
data.get("status"),
|
||
data.get("notes"),
|
||
trainer_notes_val,
|
||
tpl_id_val,
|
||
)
|
||
+ tuple(lead_params)
|
||
+ tuple(assist_params)
|
||
+ (unit_id,),
|
||
)
|
||
|
||
content_handled = False
|
||
if not is_blueprint and data.get("reset_from_template"):
|
||
tid = tpl_id_val or unit_row.get("plan_template_id")
|
||
if not tid:
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
|
||
)
|
||
_template_access(cur, tid, profile_id, role)
|
||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||
cur.execute(
|
||
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
||
)
|
||
_instantiate_from_template(cur, unit_id, tid)
|
||
content_handled = True
|
||
|
||
if not content_handled and "sections" in data:
|
||
_replace_unit_sections(cur, unit_id, data["sections"] or [])
|
||
elif not content_handled and "exercises" in data:
|
||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
||
|
||
conn.commit()
|
||
|
||
return get_training_unit(unit_id, tenant)
|
||
|
||
|
||
@router.delete("/training-units/{unit_id}")
|
||
def delete_training_unit(unit_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)
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT tu.created_by, tu.framework_slot_id, tg.club_id AS group_club_id
|
||
FROM training_units tu
|
||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
||
WHERE tu.id = %s
|
||
""",
|
||
(unit_id,),
|
||
)
|
||
|
||
unit = cur.fetchone()
|
||
|
||
if not unit:
|
||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
||
|
||
if unit.get("framework_slot_id"):
|
||
raise HTTPException(
|
||
status_code=400,
|
||
detail="Blueprint-Einheiten werden über das Rahmenprogramm verwaltet, nicht hier gelöscht.",
|
||
)
|
||
|
||
_assert_delete_training_unit(
|
||
cur,
|
||
role,
|
||
unit["created_by"],
|
||
profile_id,
|
||
unit.get("group_club_id"),
|
||
)
|
||
|
||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_id,))
|
||
conn.commit()
|
||
|
||
return {"ok": True}
|
||
|
||
|
||
@router.post("/training-units/from-framework-slot")
|
||
def create_training_unit_from_framework_slot(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
|
||
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 Trainingseinheiten erstellen")
|
||
|
||
raw_sid = data.get("framework_slot_id")
|
||
try:
|
||
slot_id = int(raw_sid)
|
||
except (TypeError, ValueError):
|
||
raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
|
||
if slot_id < 1:
|
||
raise HTTPException(status_code=400, detail="framework_slot_id ist ungültig")
|
||
|
||
group_id = data.get("group_id")
|
||
planned_date = data.get("planned_date")
|
||
if not group_id or not planned_date:
|
||
raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT fp.created_by FROM training_framework_slots s
|
||
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
|
||
WHERE s.id = %s
|
||
""",
|
||
(slot_id,),
|
||
)
|
||
fw_row = cur.fetchone()
|
||
if not fw_row:
|
||
raise HTTPException(status_code=404, detail="Rahmen-Slot nicht gefunden")
|
||
|
||
if role not in ["admin", "superadmin"]:
|
||
if fw_row["created_by"] is not None and fw_row["created_by"] != profile_id:
|
||
raise HTTPException(
|
||
status_code=403,
|
||
detail="Keine Berechtigung für dieses Rahmenprogramm",
|
||
)
|
||
|
||
cur.execute(
|
||
"SELECT id FROM training_units WHERE framework_slot_id = %s",
|
||
(slot_id,),
|
||
)
|
||
blueprint = cur.fetchone()
|
||
if not blueprint:
|
||
raise HTTPException(status_code=404, detail="Keine Blueprint-Einheit für diesen Slot")
|
||
|
||
_can_access_group_for_create(cur, int(group_id), profile_id, role)
|
||
|
||
new_id = _copy_blueprint_into_scheduled_unit(
|
||
cur,
|
||
int(blueprint["id"]),
|
||
int(group_id),
|
||
str(planned_date),
|
||
profile_id,
|
||
slot_id,
|
||
)
|
||
|
||
conn.commit()
|
||
|
||
return get_training_unit(new_id, tenant)
|
||
|
||
|
||
@router.post("/training-units/quick-create")
|
||
def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_context)):
|
||
profile_id = tenant.profile_id
|
||
|
||
group_id = data.get("group_id")
|
||
planned_date = data.get("planned_date")
|
||
plan_template_id = _optional_positive_int(data.get("plan_template_id"), "plan_template_id")
|
||
|
||
if not group_id or not planned_date:
|
||
raise HTTPException(status_code=400, detail="group_id und planned_date sind Pflichtfelder")
|
||
|
||
with get_db() as conn:
|
||
cur = get_cursor(conn)
|
||
|
||
cur.execute(
|
||
"""
|
||
SELECT weekday, time_start, time_end, trainer_id, co_trainer_ids
|
||
FROM training_groups
|
||
WHERE id = %s
|
||
""",
|
||
(group_id,),
|
||
)
|
||
|
||
group = cur.fetchone()
|
||
|
||
if not group:
|
||
raise HTTPException(status_code=404, detail="Trainingsgruppe nicht gefunden")
|
||
|
||
role = tenant.global_role
|
||
co_trainers = group["co_trainer_ids"] or []
|
||
|
||
if not _has_planning_role(role):
|
||
raise HTTPException(status_code=403, detail="Nur Trainer dürfen Trainingseinheiten erstellen")
|
||
|
||
if role not in ["admin", "superadmin"]:
|
||
if group["trainer_id"] != profile_id and profile_id not in co_trainers:
|
||
raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Gruppe")
|
||
|
||
tpl_id_safe = None
|
||
if plan_template_id:
|
||
_template_access(cur, plan_template_id, profile_id, role)
|
||
tpl_id_safe = plan_template_id
|
||
|
||
cur.execute(
|
||
"""
|
||
INSERT INTO training_units (
|
||
group_id, planned_date,
|
||
planned_time_start, planned_time_end,
|
||
status, created_by, plan_template_id
|
||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||
RETURNING id
|
||
""",
|
||
(
|
||
group_id,
|
||
planned_date,
|
||
group["time_start"],
|
||
group["time_end"],
|
||
"planned",
|
||
profile_id,
|
||
tpl_id_safe,
|
||
),
|
||
)
|
||
|
||
unit_id = cur.fetchone()["id"]
|
||
|
||
if tpl_id_safe:
|
||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||
|
||
conn.commit()
|
||
|
||
return get_training_unit(unit_id, tenant)
|
||
|