chore: update versioning and enhance training unit features
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 39s

- Incremented APP_VERSION to 0.8.10 and DB_SCHEMA_VERSION to 20260505037.
- Added changelog entry for version 0.8.10 detailing database and API changes.
- Refactored training framework program handling to support cloning training units from framework slots.
- Improved permission checks for training units based on framework slot associations.
- Introduced new API endpoint for creating training units from framework slots.
This commit is contained in:
Lars 2026-05-05 13:31:26 +02:00
parent b4495e39c1
commit 7e21b44604
5 changed files with 525 additions and 85 deletions

View File

@ -0,0 +1,130 @@
-- Migration 037: Rahmen-Slot-„Blueprint“ = eine training_units-Zeile (Ablauf wie echte Einheit)
-- training_framework_slot_exercises migriert nach training_unit_sections / training_unit_section_items,
-- dann entfernt.
-- ── Neue Spalten ───────────────────────────────────────────────────────────────
ALTER TABLE training_units
ADD COLUMN IF NOT EXISTS framework_slot_id INT REFERENCES training_framework_slots(id)
ON DELETE CASCADE,
ADD COLUMN IF NOT EXISTS origin_framework_slot_id INT REFERENCES training_framework_slots(id)
ON DELETE SET NULL;
-- Genau eine Blueprint-Einheit pro Slot (PostgreSQL UNIQUE erlaubt mehrere NULLs — hier Partial Index)
DROP INDEX IF EXISTS uq_training_units_blueprint_slot;
CREATE UNIQUE INDEX uq_training_units_blueprint_slot
ON training_units(framework_slot_id)
WHERE framework_slot_id IS NOT NULL;
CREATE INDEX IF NOT EXISTS idx_training_units_framework_blueprint_calendar
ON training_units(planned_date, group_id)
WHERE framework_slot_id IS NULL;
CREATE INDEX IF NOT EXISTS idx_training_units_origin_slot
ON training_units(origin_framework_slot_id)
WHERE origin_framework_slot_id IS NOT NULL;
-- ── Nullable für Blueprint-Zeilen ────────────────────────────────────────────
ALTER TABLE training_units ALTER COLUMN planned_date DROP NOT NULL;
ALTER TABLE training_units ALTER COLUMN group_id DROP NOT NULL;
-- ── Für jeden Slot eine Blueprint-Einheit; vorhandene Übungen in erste Sektion ─
DO $$
DECLARE
rec RECORD;
new_uid INTEGER;
new_sec INTEGER;
BEGIN
FOR rec IN
SELECT s.id AS sid, fp.created_by AS fp_created_by
FROM training_framework_slots s
JOIN training_framework_programs fp ON fp.id = s.framework_program_id
LOOP
IF EXISTS (SELECT 1 FROM training_units tu WHERE tu.framework_slot_id = rec.sid) THEN
CONTINUE;
END IF;
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,
framework_slot_id
)
VALUES (
NULL,
NULL,
NULL,
NULL,
NULL,
'planned',
NULL,
NULL,
rec.fp_created_by,
NULL,
rec.sid
)
RETURNING id INTO new_uid;
INSERT INTO training_unit_sections (
training_unit_id,
order_index,
title,
guidance_notes
)
VALUES (new_uid, 0, 'Ablauf', NULL)
RETURNING id INTO new_sec;
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
)
SELECT
new_sec,
sf.order_index,
'exercise'::character varying(20),
sf.exercise_id,
sf.exercise_variant_id,
NULL::integer,
NULL::integer,
NULL::text,
NULL::text,
NULL::text
FROM training_framework_slot_exercises sf
WHERE sf.slot_id = rec.sid
ORDER BY sf.order_index;
END LOOP;
END $$;
DROP TABLE IF EXISTS training_framework_slot_exercises;
ALTER TABLE training_units DROP CONSTRAINT IF EXISTS chk_training_units_blueprint_vs_scheduled;
ALTER TABLE training_units
ADD CONSTRAINT chk_training_units_blueprint_vs_scheduled CHECK (
(
framework_slot_id IS NOT NULL
AND group_id IS NULL
AND planned_date IS NULL
AND origin_framework_slot_id IS NULL
)
OR (
framework_slot_id IS NULL
AND group_id IS NOT NULL
AND planned_date IS NOT NULL
)
);

View File

@ -14,7 +14,10 @@ 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_sections,
_validate_variant_for_exercise,
)
@ -35,23 +38,6 @@ def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dic
return row
def _fetch_slot_exercises(cur, slot_id: int) -> List[Dict[str, Any]]:
cur.execute(
"""
SELECT t.id, t.slot_id, t.exercise_id, t.exercise_variant_id, t.order_index,
e.title AS exercise_title,
ev.variant_name AS exercise_variant_name
FROM training_framework_slot_exercises t
LEFT JOIN exercises e ON e.id = t.exercise_id
LEFT JOIN exercise_variants ev ON ev.id = t.exercise_variant_id
WHERE t.slot_id = %s
ORDER BY t.order_index
""",
(slot_id,),
)
return [r2d(x) for x in cur.fetchall()]
def _training_type_ids(cur, framework_id: int) -> List[int]:
cur.execute(
"""
@ -101,7 +87,22 @@ def _hydrate_framework(cur, row: Dict[str, Any]) -> Dict[str, Any]:
)
slots = [r2d(s) for s in cur.fetchall()]
for s in slots:
s["exercises"] = _fetch_slot_exercises(cur, s["id"])
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["sections"] = []
s["exercises"] = []
continue
uid = row_b["id"]
s["blueprint_training_unit_id"] = uid
unit_min: Dict[str, Any] = {"id": uid}
_hydrate_training_unit_payload(cur, unit_min)
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)
@ -191,10 +192,26 @@ def _insert_goal_rows(cur, framework_id: int, goals_in: List[Any]) -> None:
)
def _insert_slots_and_exercises(
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,
) -> None:
if slots_in is None:
return
@ -221,25 +238,44 @@ def _insert_slots_and_exercises(
),
)
sid = cur.fetchone()["id"]
ex_items = slot.get("exercises") or []
for ej, raw in enumerate(ex_items):
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)
oidx = raw.get("order_index")
if oidx is None:
oidx = ej
cur.execute(
"""
INSERT INTO training_framework_slot_exercises (
slot_id, exercise_id, exercise_variant_id, order_index
) VALUES (%s, %s, %s, %s)
""",
(sid, eid, vid, int(oidx)),
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, framework_slot_id
) VALUES (
NULL, NULL,
NULL, NULL, NULL,
'planned', NULL, NULL,
%s, NULL, %s
)
RETURNING id
""",
(profile_id, sid),
)
bid = cur.fetchone()["id"]
sections_in = slot.get("sections")
exercises_in = slot.get("exercises")
if 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")
@ -334,7 +370,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
)
fid = cur.fetchone()["id"]
_insert_goal_rows(cur, fid, goals_in)
_insert_slots_and_exercises(cur, fid, slots_in)
_insert_slots_and_blueprints(cur, fid, slots_in, profile_id)
_replace_training_types(cur, fid, tt_ids)
_replace_target_groups(cur, fid, tg_ids)
conn.commit()
@ -431,7 +467,7 @@ def update_training_framework_program(framework_id: int, data: dict, session=Dep
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
(framework_id,),
)
_insert_slots_and_exercises(cur, framework_id, data.get("slots") or [])
_insert_slots_and_blueprints(cur, framework_id, data.get("slots") or [], profile_id)
if header_fields or "goals" in data or "slots" in data or "training_type_ids" in data or "target_group_ids" in data:
cur.execute(

View File

@ -67,10 +67,13 @@ def _can_access_group_for_create(cur, group_id: int, profile_id: int, role: str)
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,
tg.trainer_id, tg.co_trainer_ids
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_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,),
@ -84,6 +87,16 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
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 (
@ -138,6 +151,116 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
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)):
@ -527,6 +650,8 @@ def list_training_units(
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
params.extend([profile_id, profile_id])
where.append("tu.framework_slot_id IS NULL")
if group_id:
where.append("tu.group_id = %s")
params.append(group_id)
@ -586,12 +711,31 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
unit = r2d(unit)
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit["group_id"],))
group = cur.fetchone()
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:
gid = unit.get("group_id")
if not gid:
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
if role not in ["admin", "superadmin"]:
if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (gid,))
group = cur.fetchone()
if role not in ["admin", "superadmin"]:
if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id):
raise HTTPException(status_code=403, detail="Keine Berechtigung")
_hydrate_training_unit_payload(cur, unit)
return unit
@ -671,6 +815,8 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
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, ""):
@ -690,43 +836,81 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth)
else:
trainer_notes_val = data.get("trainer_notes")
cur.execute(
"""
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()
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,
unit_id,
),
)
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.execute(
"""
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()
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,
unit_id,
),
)
content_handled = False
if data.get("reset_from_template"):
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(
@ -761,7 +945,7 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
cur = get_cursor(conn)
cur.execute(
"SELECT created_by FROM training_units WHERE id = %s",
"SELECT created_by, framework_slot_id FROM training_units WHERE id = %s",
(unit_id,),
)
@ -770,6 +954,12 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
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,))
@ -778,6 +968,74 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
return {"ok": True}
@router.post("/training-units/from-framework-slot")
def create_training_unit_from_framework_slot(data: dict, session=Depends(require_auth)):
"""Geplante Einheit aus Rahmen-Slot-Blueprint kopieren (Lineage über origin_framework_slot_id)."""
profile_id = session["profile_id"]
role = session.get("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, session)
@router.post("/training-units/quick-create")
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
profile_id = session["profile_id"]

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.9"
APP_VERSION = "0.8.10"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505036"
DB_SCHEMA_VERSION = "20260505037"
MODULE_VERSIONS = {
"auth": "1.0.0",
@ -14,7 +14,7 @@ MODULE_VERSIONS = {
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.4.0",
"planning": "0.5.0",
"import_wiki": "1.0.0",
"admin": "1.0.0",
"membership": "1.0.0",
@ -23,6 +23,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.10",
"date": "2026-05-05",
"changes": [
"DB 037: Rahmen-Slot-Blueprints als training_units (framework_slot_id); migration training_framework_slot_exercises → Sektionen/Items; origin_framework_slot_id für Kopien",
"API: Rahmen-Slots mit sections/exercises aus Blueprint; Kalender list_training_units ohne Blueprints; POST /api/training-units/from-framework-slot",
],
},
{
"version": "0.8.9",
"date": "2026-05-05",

View File

@ -924,6 +924,14 @@ export async function quickCreateTrainingUnit(data) {
})
}
/** Rahmen-Slot → geplante Einheit (tiefe Kopie, origin_framework_slot_id). */
export async function createTrainingUnitFromFrameworkSlot(data) {
return request('/api/training-units/from-framework-slot', {
method: 'POST',
body: JSON.stringify(data)
})
}
export async function listTrainingPlanTemplates() {
return request('/api/training-plan-templates')
}