chore: update versioning and enhance training unit features
- 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:
parent
b4495e39c1
commit
7e21b44604
130
backend/migrations/037_training_framework_blueprint_units.sql
Normal file
130
backend/migrations/037_training_framework_blueprint_units.sql
Normal 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
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
@ -14,7 +14,10 @@ from db import get_db, get_cursor, r2d
|
||||||
|
|
||||||
from routers.training_planning import (
|
from routers.training_planning import (
|
||||||
_has_planning_role,
|
_has_planning_role,
|
||||||
|
_hydrate_training_unit_payload,
|
||||||
_optional_positive_int,
|
_optional_positive_int,
|
||||||
|
_insert_sections_from_legacy_exercises,
|
||||||
|
_replace_unit_sections,
|
||||||
_validate_variant_for_exercise,
|
_validate_variant_for_exercise,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -35,23 +38,6 @@ def _framework_access(cur, framework_id: int, profile_id: int, role: str) -> Dic
|
||||||
return row
|
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]:
|
def _training_type_ids(cur, framework_id: int) -> List[int]:
|
||||||
cur.execute(
|
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()]
|
slots = [r2d(s) for s in cur.fetchall()]
|
||||||
for s in slots:
|
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["slots"] = slots
|
||||||
row["training_type_ids"] = _training_type_ids(cur, fid)
|
row["training_type_ids"] = _training_type_ids(cur, fid)
|
||||||
row["target_group_ids"] = _target_group_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,
|
cur,
|
||||||
framework_id: int,
|
framework_id: int,
|
||||||
slots_in: Optional[List[Any]],
|
slots_in: Optional[List[Any]],
|
||||||
|
profile_id: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
if slots_in is None:
|
if slots_in is None:
|
||||||
return
|
return
|
||||||
|
|
@ -221,25 +238,44 @@ def _insert_slots_and_exercises(
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
sid = cur.fetchone()["id"]
|
sid = cur.fetchone()["id"]
|
||||||
ex_items = slot.get("exercises") or []
|
|
||||||
for ej, raw in enumerate(ex_items):
|
cur.execute(
|
||||||
eid = raw.get("exercise_id")
|
"""
|
||||||
if not eid:
|
INSERT INTO training_units (
|
||||||
continue
|
group_id, planned_date,
|
||||||
eid = int(eid)
|
planned_time_start, planned_time_end, planned_focus,
|
||||||
vid = _optional_positive_int(raw.get("exercise_variant_id"), "exercise_variant_id")
|
status, notes, trainer_notes,
|
||||||
_validate_variant_for_exercise(cur, eid, vid)
|
created_by, plan_template_id, framework_slot_id
|
||||||
oidx = raw.get("order_index")
|
) VALUES (
|
||||||
if oidx is None:
|
NULL, NULL,
|
||||||
oidx = ej
|
NULL, NULL, NULL,
|
||||||
cur.execute(
|
'planned', NULL, NULL,
|
||||||
"""
|
%s, NULL, %s
|
||||||
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)),
|
|
||||||
)
|
)
|
||||||
|
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")
|
@router.get("/training-framework-programs")
|
||||||
|
|
@ -334,7 +370,7 @@ def create_training_framework_program(data: dict, session=Depends(require_auth))
|
||||||
)
|
)
|
||||||
fid = cur.fetchone()["id"]
|
fid = cur.fetchone()["id"]
|
||||||
_insert_goal_rows(cur, fid, goals_in)
|
_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_training_types(cur, fid, tt_ids)
|
||||||
_replace_target_groups(cur, fid, tg_ids)
|
_replace_target_groups(cur, fid, tg_ids)
|
||||||
conn.commit()
|
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",
|
"DELETE FROM training_framework_slots WHERE framework_program_id = %s",
|
||||||
(framework_id,),
|
(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:
|
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(
|
cur.execute(
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""
|
"""
|
||||||
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id,
|
SELECT tu.id, tu.created_by, tu.group_id, tu.plan_template_id, tu.framework_slot_id,
|
||||||
tg.trainer_id, tg.co_trainer_ids
|
tg.trainer_id, tg.co_trainer_ids,
|
||||||
|
fwp.created_by AS framework_created_by
|
||||||
FROM training_units tu
|
FROM training_units tu
|
||||||
LEFT JOIN training_groups tg ON tu.group_id = tg.id
|
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
|
WHERE tu.id = %s
|
||||||
""",
|
""",
|
||||||
(unit_id,),
|
(unit_id,),
|
||||||
|
|
@ -84,6 +87,16 @@ def _training_unit_guard_row(cur, unit_id: int) -> Dict[str, Any]:
|
||||||
def _assert_training_unit_permission(
|
def _assert_training_unit_permission(
|
||||||
cur, unit_row: Dict[str, Any], profile_id: int, role: str
|
cur, unit_row: Dict[str, Any], profile_id: int, role: str
|
||||||
) -> None:
|
) -> 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 []
|
co_trainers = unit_row["co_trainer_ids"] or []
|
||||||
if role not in ["admin", "superadmin"]:
|
if role not in ["admin", "superadmin"]:
|
||||||
if (
|
if (
|
||||||
|
|
@ -138,6 +151,116 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||||
return secs
|
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:
|
def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
||||||
flat: List[Dict[str, Any]] = []
|
flat: List[Dict[str, Any]] = []
|
||||||
for sec in sorted(unit.get("sections", []), key=lambda s: s.get("order_index", 0)):
|
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)")
|
where.append("(tu.created_by = %s OR tg.trainer_id = %s)")
|
||||||
params.extend([profile_id, profile_id])
|
params.extend([profile_id, profile_id])
|
||||||
|
|
||||||
|
where.append("tu.framework_slot_id IS NULL")
|
||||||
|
|
||||||
if group_id:
|
if group_id:
|
||||||
where.append("tu.group_id = %s")
|
where.append("tu.group_id = %s")
|
||||||
params.append(group_id)
|
params.append(group_id)
|
||||||
|
|
@ -586,12 +711,31 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
|
|
||||||
unit = r2d(unit)
|
unit = r2d(unit)
|
||||||
|
|
||||||
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (unit["group_id"],))
|
if unit.get("framework_slot_id"):
|
||||||
group = cur.fetchone()
|
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"]:
|
cur.execute("SELECT trainer_id FROM training_groups WHERE id = %s", (gid,))
|
||||||
if unit["created_by"] != profile_id and (not group or group["trainer_id"] != profile_id):
|
group = cur.fetchone()
|
||||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
|
||||||
|
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)
|
_hydrate_training_unit_payload(cur, unit)
|
||||||
return 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)
|
unit_row = _training_unit_guard_row(cur, unit_id)
|
||||||
_assert_training_unit_permission(cur, unit_row, profile_id, role)
|
_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_upd = data.get("plan_template_id") if "plan_template_id" in data else None
|
||||||
tpl_id_val = None
|
tpl_id_val = None
|
||||||
if tpl_upd not in (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:
|
else:
|
||||||
trainer_notes_val = data.get("trainer_notes")
|
trainer_notes_val = data.get("trainer_notes")
|
||||||
|
|
||||||
cur.execute(
|
if is_blueprint:
|
||||||
"""
|
if data.get("reset_from_template"):
|
||||||
UPDATE training_units SET
|
raise HTTPException(
|
||||||
planned_date = COALESCE(%s, planned_date),
|
status_code=400,
|
||||||
planned_time_start = %s,
|
detail="Rahmen-Blueprints können nicht aus einer Vorlage zurückgesetzt werden",
|
||||||
planned_time_end = %s,
|
)
|
||||||
planned_focus = %s,
|
if tpl_upd not in (None, ""):
|
||||||
actual_date = %s,
|
raise HTTPException(
|
||||||
actual_time_start = %s,
|
status_code=400,
|
||||||
actual_time_end = %s,
|
detail="plan_template_id ist bei Rahmen-Blueprints nicht zulässig",
|
||||||
attendance_count = %s,
|
)
|
||||||
status = %s,
|
blueprint_fields = []
|
||||||
notes = %s,
|
blueprint_params: List[Any] = []
|
||||||
trainer_notes = %s,
|
if "planned_focus" in data:
|
||||||
plan_template_id = COALESCE(%s, plan_template_id),
|
blueprint_fields.append("planned_focus = %s")
|
||||||
updated_at = NOW()
|
blueprint_params.append(data.get("planned_focus"))
|
||||||
WHERE id = %s
|
if "planned_time_start" in data:
|
||||||
""",
|
blueprint_fields.append("planned_time_start = %s")
|
||||||
(
|
blueprint_params.append(data.get("planned_time_start"))
|
||||||
data.get("planned_date"),
|
if "planned_time_end" in data:
|
||||||
data.get("planned_time_start"),
|
blueprint_fields.append("planned_time_end = %s")
|
||||||
data.get("planned_time_end"),
|
blueprint_params.append(data.get("planned_time_end"))
|
||||||
data.get("planned_focus"),
|
if "notes" in data:
|
||||||
data.get("actual_date"),
|
blueprint_fields.append("notes = %s")
|
||||||
data.get("actual_time_start"),
|
blueprint_params.append(data.get("notes"))
|
||||||
data.get("actual_time_end"),
|
blueprint_fields.append("trainer_notes = %s")
|
||||||
data.get("attendance_count"),
|
blueprint_params.append(trainer_notes_val)
|
||||||
data.get("status"),
|
blueprint_params.append(unit_id)
|
||||||
data.get("notes"),
|
cur.execute(
|
||||||
trainer_notes_val,
|
f"""
|
||||||
tpl_id_val,
|
UPDATE training_units SET
|
||||||
unit_id,
|
{", ".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
|
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")
|
tid = tpl_id_val or unit_row.get("plan_template_id")
|
||||||
if not tid:
|
if not tid:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -761,7 +945,7 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
|
|
||||||
cur.execute(
|
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,),
|
(unit_id,),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -770,6 +954,12 @@ def delete_training_unit(unit_id: int, session=Depends(require_auth)):
|
||||||
if not unit:
|
if not unit:
|
||||||
raise HTTPException(status_code=404, detail="Trainingseinheit nicht gefunden")
|
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)
|
_assert_delete_training_unit(role, unit["created_by"], profile_id)
|
||||||
|
|
||||||
cur.execute("DELETE FROM training_units WHERE id = %s", (unit_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}
|
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")
|
@router.post("/training-units/quick-create")
|
||||||
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
def quick_create_training_unit(data: dict, session=Depends(require_auth)):
|
||||||
profile_id = session["profile_id"]
|
profile_id = session["profile_id"]
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.9"
|
APP_VERSION = "0.8.10"
|
||||||
BUILD_DATE = "2026-05-05"
|
BUILD_DATE = "2026-05-05"
|
||||||
DB_SCHEMA_VERSION = "20260505036"
|
DB_SCHEMA_VERSION = "20260505037"
|
||||||
|
|
||||||
MODULE_VERSIONS = {
|
MODULE_VERSIONS = {
|
||||||
"auth": "1.0.0",
|
"auth": "1.0.0",
|
||||||
|
|
@ -14,7 +14,7 @@ MODULE_VERSIONS = {
|
||||||
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
|
"exercises": "2.3.0", # Progressionsgraph: Sequenz-API, Varianten-Knoten, UX Ketten (Migration 034)
|
||||||
"training_units": "0.1.0",
|
"training_units": "0.1.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.4.0",
|
"planning": "0.5.0",
|
||||||
"import_wiki": "1.0.0",
|
"import_wiki": "1.0.0",
|
||||||
"admin": "1.0.0",
|
"admin": "1.0.0",
|
||||||
"membership": "1.0.0",
|
"membership": "1.0.0",
|
||||||
|
|
@ -23,6 +23,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.9",
|
||||||
"date": "2026-05-05",
|
"date": "2026-05-05",
|
||||||
|
|
|
||||||
|
|
@ -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() {
|
export async function listTrainingPlanTemplates() {
|
||||||
return request('/api/training-plan-templates')
|
return request('/api/training-plan-templates')
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user