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 (
|
||||
_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(
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user