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

1318 lines
48 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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,
exercise_visible_to_profile,
is_platform_admin,
)
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 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:
raise HTTPException(
status_code=403, detail="Nur der zuständige Trainer darf für diese Gruppe planen"
)
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,
tg.trainer_id, tg.co_trainer_ids,
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_trainers = unit_row["co_trainer_ids"] or []
if role not in ["admin", "superadmin"]:
if (
unit_row["created_by"] != profile_id
and unit_row["trainer_id"] != profile_id
and profile_id not in co_trainers
and unit_row.get("lead_trainer_profile_id") != profile_id
):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
def _assert_delete_training_unit(role: str, created_by: int, profile_id: int) -> None:
if role not in ["admin", "superadmin"] and created_by != profile_id:
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: mindestens eine aktive Gruppe im Verein als Trainer/Co-Trainer."""
if role in ("admin", "superadmin"):
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,
) -> Optional[int]:
"""NULL = Vertretung aufheben; sonst Profil-ID mit Profil-Check und Gruppenkontext."""
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")
if role in ("admin", "superadmin"):
return nid
if nid == profile_id:
return nid
cur.execute(
"SELECT trainer_id, co_trainer_ids FROM training_groups WHERE id = %s",
(group_id,),
)
gr = cur.fetchone()
if not gr:
raise HTTPException(status_code=400, detail="Trainingsgruppe nicht gefunden")
eligible = {gr["trainer_id"]} if gr.get("trainer_id") else set()
for x in gr.get("co_trainer_ids") or []:
eligible.add(x)
if nid in eligible:
return nid
raise HTTPException(
status_code=403,
detail="Lead-Trainer kann nur eigene Person, Haupttrainer oder Co-Trainer der Gruppe sein",
)
# 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 exercise_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),
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 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")
cob = gr["co_trainer_ids"] or []
if gr["trainer_id"] != profile_id and profile_id not in cob:
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,
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 = []
if role not in ["admin", "superadmin"]:
where.append(
"(tu.created_by = %s OR tg.trainer_id = %s OR "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> jsonb_build_array(%s::int)))"
)
params.extend([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 "
"(tg.co_trainer_ids IS NOT NULL AND tg.co_trainer_ids @> 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 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,
COALESCE(tu.lead_trainer_profile_id, tg.trainer_id) AS effective_lead_trainer_profile_id,
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(
"""
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
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id
""",
(
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,
),
)
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:
lead_sql = ""
lead_params: List[Any] = []
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,
)
lead_sql = ", lead_trainer_profile_id = %s"
lead_params.append(nl)
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}
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)
+ (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 created_by, framework_slot_id FROM training_units WHERE 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(role, unit["created_by"], profile_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)