All checks were successful
Deploy Development / deploy (push) Successful in 43s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 13s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m29s
- Added SQL aggregations for session duration (min/max) and goal titles in the training framework programs query. - Updated the TrainingPlanningFrameworkImportModal component to include filtering options for focus areas, training types, and target groups. - Implemented session duration display in the TrainingFrameworkProgramsListPage, improving user visibility of program details. - Introduced utility functions for formatting session duration ranges, enhancing the overall user experience in training planning.
730 lines
27 KiB
Python
730 lines
27 KiB
Python
"""
|
|
Trainingsrahmenprogramm — wiederverwendbare Vorlage (Bibliothek) über mehrere Session-Slots.
|
|
|
|
Zuordnung zu Trainingsgruppen / konkreten Einheiten erfolgt aus der Planung (Kopie + Lineage),
|
|
nicht über group_id oder training_unit_id am Rahmen.
|
|
Lesen wie Übungen (official / private / club); Bearbeiten wie Übungen; Löschen nach Rolle (s. club_tenancy).
|
|
"""
|
|
from typing import Any, Dict, List, Optional, Sequence
|
|
|
|
from fastapi import APIRouter, Depends, HTTPException
|
|
|
|
from club_tenancy import (
|
|
assert_library_content_deletable,
|
|
assert_library_content_editable,
|
|
assert_library_content_governance_transition,
|
|
assert_valid_governance_visibility,
|
|
is_platform_admin,
|
|
library_content_visible_to_profile,
|
|
)
|
|
from db import get_db, get_cursor, r2d
|
|
|
|
from routers.training_planning import (
|
|
_has_planning_role,
|
|
_hydrate_training_unit_payload,
|
|
_optional_positive_int,
|
|
_insert_sections_from_legacy_exercises,
|
|
_replace_unit_phases,
|
|
_replace_unit_sections,
|
|
_validate_variant_for_exercise,
|
|
)
|
|
|
|
from tenant_context import TenantContext, get_tenant_context, library_content_visibility_sql
|
|
|
|
router = APIRouter(prefix="/api", tags=["training_framework_programs"])
|
|
_VALID_VISIBILITY = frozenset({"private", "club", "official"})
|
|
|
|
|
|
def _fetch_framework_row(cur, framework_id: int) -> Dict[str, Any]:
|
|
cur.execute("SELECT * FROM training_framework_programs WHERE id = %s", (framework_id,))
|
|
r = cur.fetchone()
|
|
if not r:
|
|
raise HTTPException(status_code=404, detail="Trainingsrahmen nicht gefunden")
|
|
return r2d(r)
|
|
|
|
|
|
def _framework_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 "private",
|
|
row.get("club_id"),
|
|
row.get("created_by"),
|
|
role,
|
|
):
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für diesen Trainingsrahmen")
|
|
|
|
|
|
def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
|
row = _fetch_framework_row(cur, framework_id)
|
|
_framework_assert_readable(cur, row, profile_id, role)
|
|
return row
|
|
|
|
|
|
def _response_framework_detail(framework_id: int, profile_id: int, role: str) -> Dict[str, Any]:
|
|
"""Einzelabruf nach Schreiboperation (ohne FastAPI-Depends-Schleife)."""
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
row = _framework_access(cur, framework_id, profile_id, role)
|
|
return _hydrate_framework(cur, row)
|
|
|
|
|
|
def _training_type_ids(cur, framework_id: int) -> List[int]:
|
|
cur.execute(
|
|
"""
|
|
SELECT training_type_id
|
|
FROM training_framework_program_training_types
|
|
WHERE framework_program_id = %s
|
|
ORDER BY training_type_id
|
|
""",
|
|
(framework_id,),
|
|
)
|
|
return [r["training_type_id"] for r in cur.fetchall()]
|
|
|
|
|
|
def _target_group_ids(cur, framework_id: int) -> List[int]:
|
|
cur.execute(
|
|
"""
|
|
SELECT target_group_id
|
|
FROM training_framework_program_target_groups
|
|
WHERE framework_program_id = %s
|
|
ORDER BY target_group_id
|
|
""",
|
|
(framework_id,),
|
|
)
|
|
return [r["target_group_id"] for r in cur.fetchall()]
|
|
|
|
|
|
def _focus_area_ids(cur, framework_id: int) -> List[int]:
|
|
cur.execute(
|
|
"""
|
|
SELECT focus_area_id
|
|
FROM training_framework_program_focus_areas
|
|
WHERE framework_program_id = %s
|
|
ORDER BY focus_area_id
|
|
""",
|
|
(framework_id,),
|
|
)
|
|
return [r["focus_area_id"] for r in cur.fetchall()]
|
|
|
|
|
|
def _style_direction_ids(cur, framework_id: int) -> List[int]:
|
|
cur.execute(
|
|
"""
|
|
SELECT style_direction_id
|
|
FROM training_framework_program_style_directions
|
|
WHERE framework_program_id = %s
|
|
ORDER BY style_direction_id
|
|
""",
|
|
(framework_id,),
|
|
)
|
|
return [r["style_direction_id"] for r in cur.fetchall()]
|
|
|
|
|
|
def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
|
|
fid = row["id"]
|
|
cur.execute(
|
|
"""
|
|
SELECT id, framework_program_id, sort_order, title, notes
|
|
FROM training_framework_goals
|
|
WHERE framework_program_id = %s
|
|
ORDER BY sort_order
|
|
""",
|
|
(fid,),
|
|
)
|
|
row["goals"] = [r2d(g) for g in cur.fetchall()]
|
|
cur.execute(
|
|
"""
|
|
SELECT id, framework_program_id, sort_order, title, notes
|
|
FROM training_framework_slots
|
|
WHERE framework_program_id = %s
|
|
ORDER BY sort_order
|
|
""",
|
|
(fid,),
|
|
)
|
|
slots = [r2d(s) for s in cur.fetchall()]
|
|
for s in slots:
|
|
cur.execute(
|
|
"SELECT id FROM training_units WHERE framework_slot_id = %s",
|
|
(s["id"],),
|
|
)
|
|
row_b = cur.fetchone()
|
|
if not row_b:
|
|
s["blueprint_training_unit_id"] = None
|
|
s["phases"] = []
|
|
s["sections"] = []
|
|
s["exercises"] = []
|
|
continue
|
|
uid = row_b["id"]
|
|
s["blueprint_training_unit_id"] = uid
|
|
unit_min: Dict[str, Any] = {"id": uid}
|
|
cur.execute(
|
|
"SELECT planned_duration_min FROM training_units WHERE id = %s",
|
|
(uid,),
|
|
)
|
|
urow = cur.fetchone()
|
|
s["planned_duration_min"] = (
|
|
urow["planned_duration_min"] if urow else None
|
|
)
|
|
_hydrate_training_unit_payload(cur, unit_min)
|
|
s["phases"] = unit_min.get("phases", [])
|
|
s["sections"] = unit_min.get("sections", [])
|
|
s["exercises"] = unit_min.get("exercises", [])
|
|
row["slots"] = slots
|
|
row["training_type_ids"] = _training_type_ids(cur, fid)
|
|
row["target_group_ids"] = _target_group_ids(cur, fid)
|
|
row["focus_area_ids"] = _focus_area_ids(cur, fid)
|
|
row["style_direction_ids"] = _style_direction_ids(cur, fid)
|
|
return row
|
|
|
|
|
|
def _assert_visibility(val: Optional[str]) -> Optional[str]:
|
|
if val is None:
|
|
return None
|
|
if val not in _VALID_VISIBILITY:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="visibility muss private, club oder official sein",
|
|
)
|
|
return val
|
|
|
|
|
|
def _parse_positive_int_ids(raw: Any, label: str) -> List[int]:
|
|
if raw is None:
|
|
return []
|
|
if not isinstance(raw, list):
|
|
raise HTTPException(status_code=400, detail=f"{label} muss eine Liste von IDs sein")
|
|
out: List[int] = []
|
|
for item in raw:
|
|
if item in (None, ""):
|
|
continue
|
|
try:
|
|
n = int(item)
|
|
except (TypeError, ValueError):
|
|
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID") from None
|
|
if n <= 0:
|
|
raise HTTPException(status_code=400, detail=f"{label}: ungültige ID")
|
|
if n not in out:
|
|
out.append(n)
|
|
return out
|
|
|
|
|
|
def _replace_training_types(cur, framework_id: int, ids: Sequence[int]) -> None:
|
|
cur.execute(
|
|
"DELETE FROM training_framework_program_training_types WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
for tid in ids:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_program_training_types (framework_program_id, training_type_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(framework_id, tid),
|
|
)
|
|
|
|
|
|
def _replace_target_groups(cur, framework_id: int, ids: Sequence[int]) -> None:
|
|
cur.execute(
|
|
"DELETE FROM training_framework_program_target_groups WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
for gid in ids:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_program_target_groups (framework_program_id, target_group_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(framework_id, gid),
|
|
)
|
|
|
|
|
|
def _replace_focus_areas(cur, framework_id: int, ids: Sequence[int]) -> None:
|
|
cur.execute(
|
|
"DELETE FROM training_framework_program_focus_areas WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
for fid in ids:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_program_focus_areas (framework_program_id, focus_area_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(framework_id, fid),
|
|
)
|
|
|
|
|
|
def _replace_style_directions(cur, framework_id: int, ids: Sequence[int]) -> None:
|
|
cur.execute(
|
|
"DELETE FROM training_framework_program_style_directions WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
for sid in ids:
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_program_style_directions (framework_program_id, style_direction_id)
|
|
VALUES (%s, %s)
|
|
ON CONFLICT DO NOTHING
|
|
""",
|
|
(framework_id, sid),
|
|
)
|
|
|
|
|
|
def _parse_context_ids_from_payload(data: dict) -> tuple:
|
|
"""focus_area_ids / style_direction_ids (M:N); Legacy focus_area_id / style_direction_id."""
|
|
fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids")
|
|
if not fa_ids and data.get("focus_area_id") not in (None, ""):
|
|
one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
|
if one is not None:
|
|
fa_ids = [one]
|
|
sd_ids = _parse_positive_int_ids(data.get("style_direction_ids"), "style_direction_ids")
|
|
if not sd_ids and data.get("style_direction_id") not in (None, ""):
|
|
one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
|
if one is not None:
|
|
sd_ids = [one]
|
|
return fa_ids, sd_ids
|
|
|
|
|
|
def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
|
|
if not goals_in:
|
|
raise HTTPException(status_code=400, detail="Mindestens ein Entwicklungsziel (goals) ist erforderlich")
|
|
for gi, g in enumerate(goals_in):
|
|
title_g = (g.get("title") or "").strip()
|
|
if not title_g:
|
|
raise HTTPException(status_code=400, detail="Jedes Ziel braucht ein nicht-leeres title")
|
|
order_ix = g.get("sort_order")
|
|
if order_ix is None:
|
|
order_ix = gi
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_goals (
|
|
framework_program_id, sort_order, title, notes
|
|
) VALUES (%s, %s, %s, %s)
|
|
""",
|
|
(framework_id, int(order_ix), title_g[:500], g.get("notes")),
|
|
)
|
|
|
|
|
|
def _insert_default_blueprint_section(cur, blueprint_unit_id: int) -> None:
|
|
"""Leerer Ablauf, falls noch keine Sektionen existieren."""
|
|
cur.execute(
|
|
"SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s",
|
|
(blueprint_unit_id,),
|
|
)
|
|
if cur.fetchone():
|
|
return
|
|
_replace_unit_sections(
|
|
cur,
|
|
blueprint_unit_id,
|
|
[{"title": "Ablauf", "order_index": 0, "guidance_notes": None, "items": []}],
|
|
)
|
|
|
|
|
|
def _insert_slots_and_blueprints(
|
|
cur,
|
|
framework_id: int,
|
|
slots_in: Optional[List[Any]],
|
|
profile_id: int,
|
|
role: str,
|
|
) -> None:
|
|
if slots_in is None:
|
|
return
|
|
for si, slot in enumerate(slots_in):
|
|
order_ix = slot.get("sort_order")
|
|
if order_ix is None:
|
|
order_ix = si
|
|
title_s = slot.get("title")
|
|
if title_s is not None:
|
|
title_s = title_s.strip() or None
|
|
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_slots (
|
|
framework_program_id, sort_order, title, notes, training_unit_id
|
|
) VALUES (%s, %s, %s, %s, NULL)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
framework_id,
|
|
int(order_ix),
|
|
title_s,
|
|
slot.get("notes"),
|
|
),
|
|
)
|
|
sid = cur.fetchone()["id"]
|
|
|
|
slot_pdur = _optional_positive_int(
|
|
slot.get("planned_duration_min"), "planned_duration_min"
|
|
)
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_units (
|
|
group_id, planned_date,
|
|
planned_time_start, planned_time_end, planned_duration_min, planned_focus,
|
|
status, notes, trainer_notes,
|
|
created_by, plan_template_id, framework_slot_id
|
|
) VALUES (
|
|
NULL, NULL,
|
|
NULL, NULL, %s, NULL,
|
|
'planned', NULL, NULL,
|
|
%s, NULL, %s
|
|
)
|
|
RETURNING id
|
|
""",
|
|
(slot_pdur, profile_id, sid),
|
|
)
|
|
bid = cur.fetchone()["id"]
|
|
|
|
phases_in = slot.get("phases")
|
|
sections_in = slot.get("sections")
|
|
exercises_in = slot.get("exercises")
|
|
|
|
if phases_in is not None and isinstance(phases_in, list) and len(phases_in) > 0:
|
|
_replace_unit_phases(cur, bid, phases_in, profile_id, role, profile_id)
|
|
elif sections_in is not None:
|
|
if len(sections_in) == 0:
|
|
_insert_default_blueprint_section(cur, bid)
|
|
else:
|
|
_replace_unit_sections(cur, bid, sections_in)
|
|
elif exercises_in is not None and len(exercises_in) > 0:
|
|
for raw in exercises_in:
|
|
eid = raw.get("exercise_id")
|
|
if not eid:
|
|
continue
|
|
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
|
_validate_variant_for_exercise(cur, int(eid), vid)
|
|
_insert_sections_from_legacy_exercises(cur, bid, exercises_in)
|
|
else:
|
|
_insert_default_blueprint_section(cur, bid)
|
|
|
|
|
|
@router.get("/training-framework-programs")
|
|
def list_training_framework_programs(tenant: TenantContext = Depends(get_tenant_context)):
|
|
profile_id = tenant.profile_id
|
|
role = tenant.global_role
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
base_sel = """
|
|
SELECT fp.*,
|
|
(SELECT COUNT(*)::int FROM training_framework_goals g WHERE g.framework_program_id = fp.id)
|
|
AS goals_count,
|
|
(SELECT COUNT(*)::int FROM training_framework_slots s WHERE s.framework_program_id = fp.id)
|
|
AS slots_count,
|
|
(SELECT COUNT(*)::int FROM training_framework_program_training_types t
|
|
WHERE t.framework_program_id = fp.id) AS training_types_count,
|
|
(SELECT COUNT(*)::int FROM training_framework_program_target_groups tg
|
|
WHERE tg.framework_program_id = fp.id) AS target_groups_count,
|
|
(
|
|
SELECT STRING_AGG(fa.name::text, ', ' ORDER BY fa.name)
|
|
FROM training_framework_program_focus_areas j
|
|
JOIN focus_areas fa ON fa.id = j.focus_area_id
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS focus_area_names_agg,
|
|
(
|
|
SELECT STRING_AGG(sd.name::text, ', ' ORDER BY sd.name)
|
|
FROM training_framework_program_style_directions j
|
|
JOIN style_directions sd ON sd.id = j.style_direction_id
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS style_direction_names_agg,
|
|
(
|
|
SELECT STRING_AGG(typ.name::text, ', ' ORDER BY typ.sort_order NULLS LAST, typ.name)
|
|
FROM training_framework_program_training_types j
|
|
JOIN training_types typ ON typ.id = j.training_type_id
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS training_type_names_agg,
|
|
(
|
|
SELECT STRING_AGG(tg.name::text, ', ' ORDER BY tg.name)
|
|
FROM training_framework_program_target_groups j
|
|
JOIN target_groups tg ON tg.id = j.target_group_id
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS target_group_names_agg,
|
|
(
|
|
SELECT STRING_AGG(g.title::text, ' | ' ORDER BY g.sort_order)
|
|
FROM training_framework_goals g
|
|
WHERE g.framework_program_id = fp.id
|
|
) AS goal_titles_agg,
|
|
(
|
|
SELECT MIN(tu.planned_duration_min)::int
|
|
FROM training_framework_slots fs
|
|
INNER JOIN training_units tu ON tu.framework_slot_id = fs.id
|
|
WHERE fs.framework_program_id = fp.id
|
|
AND tu.planned_duration_min IS NOT NULL
|
|
) AS session_duration_min,
|
|
(
|
|
SELECT MAX(tu.planned_duration_min)::int
|
|
FROM training_framework_slots fs
|
|
INNER JOIN training_units tu ON tu.framework_slot_id = fs.id
|
|
WHERE fs.framework_program_id = fp.id
|
|
AND tu.planned_duration_min IS NOT NULL
|
|
) AS session_duration_max,
|
|
(
|
|
SELECT COALESCE(json_agg(j.focus_area_id ORDER BY j.focus_area_id), '[]'::json)
|
|
FROM training_framework_program_focus_areas j
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS focus_area_ids,
|
|
(
|
|
SELECT COALESCE(json_agg(j.style_direction_id ORDER BY j.style_direction_id), '[]'::json)
|
|
FROM training_framework_program_style_directions j
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS style_direction_ids,
|
|
(
|
|
SELECT COALESCE(json_agg(j.training_type_id ORDER BY j.training_type_id), '[]'::json)
|
|
FROM training_framework_program_training_types j
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS training_type_ids,
|
|
(
|
|
SELECT COALESCE(json_agg(j.target_group_id ORDER BY j.target_group_id), '[]'::json)
|
|
FROM training_framework_program_target_groups j
|
|
WHERE j.framework_program_id = fp.id
|
|
) AS target_group_ids
|
|
FROM training_framework_programs fp
|
|
"""
|
|
vis_clause, vis_params = library_content_visibility_sql(
|
|
alias="fp",
|
|
profile_id=profile_id,
|
|
role=role,
|
|
effective_club_id=tenant.effective_club_id,
|
|
)
|
|
cur.execute(
|
|
base_sel
|
|
+ f""" WHERE ({vis_clause})
|
|
ORDER BY fp.updated_at DESC NULLS LAST, fp.title""",
|
|
vis_params,
|
|
)
|
|
return [r2d(r) for r in cur.fetchall()]
|
|
|
|
|
|
@router.get("/training-framework-programs/{framework_id}")
|
|
def get_training_framework_program(
|
|
framework_id: int, tenant: TenantContext = Depends(get_tenant_context)
|
|
):
|
|
profile_id = tenant.profile_id
|
|
role = tenant.global_role
|
|
return _response_framework_detail(framework_id, profile_id, role)
|
|
|
|
|
|
@router.post("/training-framework-programs")
|
|
def create_training_framework_program(
|
|
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 Planungsberechtigte dürfen Rahmenprogramme anlegen")
|
|
|
|
title = (data.get("title") or "").strip()
|
|
if not title:
|
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
|
|
|
vis = data.get("visibility") or "private"
|
|
vis = _assert_visibility(vis)
|
|
club_id = data.get("club_id")
|
|
if club_id in ("", []):
|
|
club_id = None
|
|
if vis == "club" and club_id is None:
|
|
club_id = tenant.effective_club_id
|
|
|
|
goals_in = data.get("goals")
|
|
slots_in = data.get("slots")
|
|
if not isinstance(goals_in, list) or not goals_in:
|
|
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
|
|
|
fa_ids, sd_ids = _parse_context_ids_from_payload(data)
|
|
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
|
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
|
|
fa_legacy = fa_ids[0] if len(fa_ids) == 1 else None
|
|
sd_legacy = sd_ids[0] if len(sd_ids) == 1 else None
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
assert_valid_governance_visibility(cur, profile_id, role, vis, club_id)
|
|
cur.execute(
|
|
"""
|
|
INSERT INTO training_framework_programs (
|
|
title, description,
|
|
planned_period_start, planned_period_end,
|
|
visibility, club_id, created_by,
|
|
focus_area_id, style_direction_id
|
|
) VALUES (%s, %s, NULL, NULL, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""",
|
|
(
|
|
title[:200],
|
|
data.get("description"),
|
|
vis,
|
|
club_id,
|
|
profile_id,
|
|
fa_legacy,
|
|
sd_legacy,
|
|
),
|
|
)
|
|
fid = cur.fetchone()["id"]
|
|
_insert_goal_rows(cur, fid, goals_in)
|
|
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id, role)
|
|
_replace_training_types(cur, fid, tt_ids)
|
|
_replace_target_groups(cur, fid, tg_ids)
|
|
_replace_focus_areas(cur, fid, fa_ids)
|
|
_replace_style_directions(cur, fid, sd_ids)
|
|
conn.commit()
|
|
|
|
return _response_framework_detail(fid, profile_id, role)
|
|
|
|
|
|
@router.put("/training-framework-programs/{framework_id}")
|
|
def update_training_framework_program(
|
|
framework_id: int, 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="Keine Berechtigung")
|
|
|
|
with get_db() as conn:
|
|
cur = get_cursor(conn)
|
|
row_prev = _fetch_framework_row(cur, framework_id)
|
|
assert_library_content_editable(cur, profile_id, role, row_prev)
|
|
|
|
merged_vis = row_prev.get("visibility") or "private"
|
|
merged_club = row_prev.get("club_id")
|
|
if "visibility" in data:
|
|
v_m = _assert_visibility(data.get("visibility"))
|
|
if v_m is None:
|
|
raise HTTPException(status_code=400, detail="visibility fehlt")
|
|
merged_vis = v_m
|
|
if "club_id" in data:
|
|
merged_club = data.get("club_id")
|
|
if merged_club in ("", []):
|
|
merged_club = None
|
|
if merged_vis == "club" and merged_club is None:
|
|
merged_club = tenant.effective_club_id
|
|
if "visibility" in data or "club_id" in data:
|
|
assert_library_content_governance_transition(
|
|
cur, profile_id, role, row_prev, merged_vis, merged_club
|
|
)
|
|
assert_valid_governance_visibility(cur, profile_id, role, merged_vis, merged_club)
|
|
|
|
header_fields = []
|
|
header_params: List[Any] = []
|
|
|
|
if "title" in data:
|
|
tit = (data.get("title") or "").strip()
|
|
if not tit:
|
|
raise HTTPException(status_code=400, detail="title ist Pflicht")
|
|
header_fields.append("title = %s")
|
|
header_params.append(tit[:200])
|
|
|
|
if "description" in data:
|
|
header_fields.append("description = %s")
|
|
header_params.append(data.get("description"))
|
|
if "visibility" in data:
|
|
header_fields.append("visibility = %s")
|
|
header_params.append(merged_vis)
|
|
if "club_id" in data:
|
|
header_fields.append("club_id = %s")
|
|
header_params.append(merged_club)
|
|
|
|
if "focus_area_ids" in data or "focus_area_id" in data:
|
|
fa_ids = _parse_positive_int_ids(data.get("focus_area_ids"), "focus_area_ids")
|
|
if not fa_ids and data.get("focus_area_id") not in (None, ""):
|
|
one = _optional_positive_int(data.get("focus_area_id"), "focus_area_id")
|
|
if one is not None:
|
|
fa_ids = [one]
|
|
header_fields.append("focus_area_id = %s")
|
|
header_params.append(fa_ids[0] if len(fa_ids) == 1 else None)
|
|
_replace_focus_areas(cur, framework_id, fa_ids)
|
|
if "style_direction_ids" in data or "style_direction_id" in data:
|
|
sd_ids = _parse_positive_int_ids(
|
|
data.get("style_direction_ids"), "style_direction_ids"
|
|
)
|
|
if not sd_ids and data.get("style_direction_id") not in (None, ""):
|
|
one = _optional_positive_int(data.get("style_direction_id"), "style_direction_id")
|
|
if one is not None:
|
|
sd_ids = [one]
|
|
header_fields.append("style_direction_id = %s")
|
|
header_params.append(sd_ids[0] if len(sd_ids) == 1 else None)
|
|
_replace_style_directions(cur, framework_id, sd_ids)
|
|
|
|
if header_fields:
|
|
header_fields.append("updated_at = NOW()")
|
|
header_params.append(framework_id)
|
|
cur.execute(
|
|
f"""
|
|
UPDATE training_framework_programs
|
|
SET {", ".join(header_fields)}
|
|
WHERE id = %s
|
|
""",
|
|
tuple(header_params),
|
|
)
|
|
|
|
if "training_type_ids" in data:
|
|
tt_ids = _parse_positive_int_ids(data.get("training_type_ids"), "training_type_ids")
|
|
_replace_training_types(cur, framework_id, tt_ids)
|
|
|
|
if "target_group_ids" in data:
|
|
tg_ids = _parse_positive_int_ids(data.get("target_group_ids"), "target_group_ids")
|
|
_replace_target_groups(cur, framework_id, tg_ids)
|
|
|
|
if "goals" in data:
|
|
goals_in = data["goals"]
|
|
if not isinstance(goals_in, list) or not goals_in:
|
|
raise HTTPException(status_code=400, detail="goals als Liste mit mindestens einem Eintrag ist Pflicht")
|
|
cur.execute(
|
|
"DELETE FROM training_framework_goals WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
_insert_goal_rows(cur, framework_id, goals_in)
|
|
|
|
if "slots" in data:
|
|
cur.execute(
|
|
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
|
(framework_id,),
|
|
)
|
|
_insert_slots_and_blueprints(
|
|
cur, framework_id, data.get("slots") or [], profile_id, role
|
|
)
|
|
|
|
if (
|
|
header_fields
|
|
or "goals" in data
|
|
or "slots" in data
|
|
or "training_type_ids" in data
|
|
or "target_group_ids" in data
|
|
or "focus_area_ids" in data
|
|
or "focus_area_id" in data
|
|
or "style_direction_ids" in data
|
|
or "style_direction_id" in data
|
|
):
|
|
cur.execute(
|
|
"UPDATE training_framework_programs SET updated_at = NOW() WHERE id = %s",
|
|
(framework_id,),
|
|
)
|
|
|
|
conn.commit()
|
|
|
|
return _response_framework_detail(framework_id, profile_id, role)
|
|
|
|
|
|
@router.delete("/training-framework-programs/{framework_id}")
|
|
def delete_training_framework_program(
|
|
framework_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_fw = _fetch_framework_row(cur, framework_id)
|
|
assert_library_content_deletable(cur, profile_id, role, row_fw)
|
|
cur.execute(
|
|
"DELETE FROM training_framework_programs WHERE id = %s",
|
|
(framework_id,),
|
|
)
|
|
conn.commit()
|
|
return {"ok": True}
|