chore(version): update version and changelog for release 0.8.137
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / k6 /health Baseline (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
Some checks failed
Test Suite / pytest-backend (push) Waiting to run
Test Suite / lint-backend (push) Waiting to run
Test Suite / build-frontend (push) Waiting to run
Test Suite / k6 /health Baseline (push) Waiting to run
Test Suite / playwright-tests (push) Waiting to run
Deploy Development / deploy (push) Has been cancelled
- Bumped APP_VERSION to 0.8.137 and updated the changelog to reflect recent changes. - Introduced Migration 063 for training unit phases and parallel streams, enhancing the structure of training units. - Updated the training planning API to support nested phases and sections, improving data retrieval for UI components. - Enhanced section handling to accommodate new phase and stream structures, ensuring compatibility with existing workflows.
This commit is contained in:
parent
220a16429c
commit
ae51d201bc
|
|
@ -77,8 +77,8 @@ Dieses Dokument **persistiert** die strukturierte Prüfung der realen Codebasis
|
|||
## 6. Implementierungspakete (Überblick)
|
||||
|
||||
0. Spike DDL + Contract-Doku
|
||||
1. Schema + Migration + hydrate/replace
|
||||
2. PUT + Module + Clone (`from-framework-slot`)
|
||||
1. **Erledigt (2026-05-14):** Migration **063** + `training_planning`: Phasen/Streams-Schema, Backfill whole_group, `GET` mit `phases`, Legacy-`sections`-PUT unverändert (eine whole_group-Phase).
|
||||
2. PUT mit echten Parallelphasen / Streams, `apply-training-module` mit Stream-Kontext, `from-framework-slot`-Kopie
|
||||
3. Planung UI
|
||||
4. Run + Coach
|
||||
5. Co-Trainer pro Stream
|
||||
|
|
|
|||
|
|
@ -0,0 +1,85 @@
|
|||
-- Migration 063: Phasen und parallele Streams pro Trainingseinheit (Grundlage Breakout).
|
||||
-- Bestehende Sektionen werden einer Default-whole_group-Phase zugeordnet.
|
||||
-- UNIQUE (training_unit_id, order_index) auf Sektionen entfällt zugunsten
|
||||
-- eindeutiger order_index je Phase bzw. je parallel_stream.
|
||||
|
||||
-- ── Phasen ───────────────────────────────────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_unit_phases (
|
||||
id SERIAL PRIMARY KEY,
|
||||
training_unit_id INT NOT NULL REFERENCES training_units(id) ON DELETE CASCADE,
|
||||
order_index INT NOT NULL,
|
||||
phase_kind VARCHAR(20) NOT NULL CHECK (phase_kind IN ('whole_group', 'parallel')),
|
||||
title VARCHAR(200),
|
||||
guidance_notes TEXT,
|
||||
UNIQUE (training_unit_id, order_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_unit_phases_unit ON training_unit_phases(training_unit_id);
|
||||
|
||||
-- ── Streams innerhalb einer Parallelphase ──────────────────────────────────
|
||||
CREATE TABLE IF NOT EXISTS training_unit_parallel_streams (
|
||||
id SERIAL PRIMARY KEY,
|
||||
phase_id INT NOT NULL REFERENCES training_unit_phases(id) ON DELETE CASCADE,
|
||||
order_index INT NOT NULL,
|
||||
title VARCHAR(200),
|
||||
notes TEXT,
|
||||
assigned_trainer_profile_ids JSONB,
|
||||
UNIQUE (phase_id, order_index)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_unit_parallel_streams_phase
|
||||
ON training_unit_parallel_streams(phase_id);
|
||||
|
||||
COMMENT ON COLUMN training_unit_parallel_streams.assigned_trainer_profile_ids IS
|
||||
'Optionale Co-Trainer-IDs (JSON-Array von Profil-IDs) für diese Teilstrecke; MVP+';
|
||||
|
||||
-- ── Sektionen: Zuordnung zu Phase (gemeinsam) oder Stream (parallel) ─────
|
||||
ALTER TABLE training_unit_sections
|
||||
ADD COLUMN IF NOT EXISTS phase_id INT REFERENCES training_unit_phases(id) ON DELETE CASCADE,
|
||||
ADD COLUMN IF NOT EXISTS parallel_stream_id INT REFERENCES training_unit_parallel_streams(id) ON DELETE CASCADE;
|
||||
|
||||
-- Backfill: je Einheit mit Sektionen eine whole_group-Phase, alle Sektionen dorthin
|
||||
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title)
|
||||
SELECT tu.id, 0, 'whole_group', NULL
|
||||
FROM training_units tu
|
||||
WHERE EXISTS (SELECT 1 FROM training_unit_sections s WHERE s.training_unit_id = tu.id)
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM training_unit_phases p
|
||||
WHERE p.training_unit_id = tu.id AND p.order_index = 0 AND p.phase_kind = 'whole_group'
|
||||
);
|
||||
|
||||
UPDATE training_unit_sections tus
|
||||
SET phase_id = p.id
|
||||
FROM training_unit_phases p
|
||||
WHERE tus.phase_id IS NULL
|
||||
AND p.training_unit_id = tus.training_unit_id
|
||||
AND p.order_index = 0
|
||||
AND p.phase_kind = 'whole_group';
|
||||
|
||||
-- Alte globale Reihenfolge-Eindeutigkeit pro Einheit entfernen
|
||||
ALTER TABLE training_unit_sections
|
||||
DROP CONSTRAINT IF EXISTS training_unit_sections_training_unit_id_order_index_key;
|
||||
|
||||
-- Genau eine Zielspalte gesetzt: gemeinsame Phase ODER paralleler Stream
|
||||
ALTER TABLE training_unit_sections
|
||||
DROP CONSTRAINT IF EXISTS training_unit_sections_phase_or_stream_chk;
|
||||
|
||||
ALTER TABLE training_unit_sections
|
||||
ADD CONSTRAINT training_unit_sections_phase_or_stream_chk CHECK (
|
||||
(phase_id IS NOT NULL AND parallel_stream_id IS NULL)
|
||||
OR (phase_id IS NULL AND parallel_stream_id IS NOT NULL)
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_phase_order
|
||||
ON training_unit_sections (phase_id, order_index)
|
||||
WHERE phase_id IS NOT NULL;
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS uq_training_unit_sections_stream_order
|
||||
ON training_unit_sections (parallel_stream_id, order_index)
|
||||
WHERE parallel_stream_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_phase
|
||||
ON training_unit_sections(phase_id) WHERE phase_id IS NOT NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_training_unit_sections_parallel_stream
|
||||
ON training_unit_sections(parallel_stream_id) WHERE parallel_stream_id IS NOT NULL;
|
||||
|
|
@ -524,11 +524,48 @@ def _optional_source_training_module_id_payload(raw_val) -> Optional[int]:
|
|||
|
||||
# ── Sektionen laden / ersetzen (Kernpfad Planungsinhalt) ──────────────────
|
||||
# Hinweis: Pro Sektion ein Items-Query (N+1) — bewusst einfach; Batching später möglich.
|
||||
|
||||
|
||||
def _clear_unit_plan_content(cur, unit_id: int) -> None:
|
||||
"""Löscht alle Planungs-Phasen der Einheit (CASCADE: Streams, Sektionen, Items)."""
|
||||
cur.execute("DELETE FROM training_unit_phases WHERE training_unit_id = %s", (unit_id,))
|
||||
|
||||
|
||||
def _ensure_default_whole_group_phase(cur, unit_id: int, *, order_index: int = 0) -> int:
|
||||
"""Legt bei Bedarf eine whole_group-Phase an; gibt phase.id zurück."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM training_unit_phases
|
||||
WHERE training_unit_id = %s AND phase_kind = 'whole_group' AND order_index = %s
|
||||
LIMIT 1
|
||||
""",
|
||||
(unit_id, order_index),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row:
|
||||
return int(row["id"])
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_phases (training_unit_id, order_index, phase_kind, title, guidance_notes)
|
||||
VALUES (%s, %s, 'whole_group', NULL, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(unit_id, order_index),
|
||||
)
|
||||
return int(cur.fetchone()["id"])
|
||||
|
||||
|
||||
_SECTION_ROWS_SQL = """
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
FROM training_unit_sections
|
||||
WHERE training_unit_id = %s
|
||||
ORDER BY order_index
|
||||
SELECT tus.id, tus.training_unit_id, tus.order_index, tus.title, tus.guidance_notes,
|
||||
tus.source_template_section_id, tus.phase_id, tus.parallel_stream_id
|
||||
FROM training_unit_sections tus
|
||||
LEFT JOIN training_unit_phases ph ON ph.id = tus.phase_id
|
||||
LEFT JOIN training_unit_parallel_streams ps ON ps.id = tus.parallel_stream_id
|
||||
LEFT JOIN training_unit_phases ph_s ON ph_s.id = ps.phase_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
ORDER BY COALESCE(ph.order_index, ph_s.order_index) ASC,
|
||||
ps.order_index ASC NULLS FIRST,
|
||||
tus.order_index ASC
|
||||
"""
|
||||
_SECTION_ITEMS_ROWS_SQL = """
|
||||
SELECT tusi.*,
|
||||
|
|
@ -593,6 +630,78 @@ def _fetch_sections(cur, unit_id: int) -> List[Dict[str, Any]]:
|
|||
return secs
|
||||
|
||||
|
||||
def _fetch_phases_nested(cur, unit_id: int) -> List[Dict[str, Any]]:
|
||||
"""Verschachtelte Phasen/Streams/Sektionen für GET (UI kann parallele Sp später nutzen)."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, phase_kind, title, guidance_notes
|
||||
FROM training_unit_phases
|
||||
WHERE training_unit_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(unit_id,),
|
||||
)
|
||||
out: List[Dict[str, Any]] = []
|
||||
for prow in cur.fetchall():
|
||||
p = r2d(prow)
|
||||
pk = str(p.get("phase_kind") or "").strip().lower()
|
||||
if pk == "whole_group":
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE phase_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(p["id"],),
|
||||
)
|
||||
secs: List[Dict[str, Any]] = []
|
||||
for srow in cur.fetchall():
|
||||
sec = r2d(srow)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
p["sections"] = secs
|
||||
p["streams"] = []
|
||||
elif pk == "parallel":
|
||||
p["sections"] = []
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, phase_id, order_index, title, notes, assigned_trainer_profile_ids
|
||||
FROM training_unit_parallel_streams
|
||||
WHERE phase_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(p["id"],),
|
||||
)
|
||||
streams: List[Dict[str, Any]] = []
|
||||
for st_row in cur.fetchall():
|
||||
st = r2d(st_row)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, training_unit_id, order_index, title, guidance_notes,
|
||||
source_template_section_id, phase_id, parallel_stream_id
|
||||
FROM training_unit_sections
|
||||
WHERE parallel_stream_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(st["id"],),
|
||||
)
|
||||
secs = []
|
||||
for sec_row in cur.fetchall():
|
||||
sec = r2d(sec_row)
|
||||
sec["items"] = _fetch_section_items_for_section(cur, sec["id"])
|
||||
secs.append(sec)
|
||||
st["sections"] = secs
|
||||
streams.append(st)
|
||||
p["streams"] = streams
|
||||
else:
|
||||
p["sections"] = []
|
||||
p["streams"] = []
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
|
||||
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)
|
||||
|
|
@ -718,18 +827,27 @@ def _flatten_exercises_from_sections(unit: Dict[str, Any]) -> None:
|
|||
|
||||
|
||||
def _hydrate_training_unit_payload(cur, unit: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""GET-Payload: verschachtelte `sections` + abgeleitete flache `exercises` (Legacy-Kompatibilität)."""
|
||||
"""GET-Payload: `phases` (verschachtelt), flache `sections` + abgeleitete `exercises` (Legacy)."""
|
||||
uid = unit["id"]
|
||||
unit["phases"] = _fetch_phases_nested(cur, uid)
|
||||
unit["sections"] = _fetch_sections(cur, uid)
|
||||
_flatten_exercises_from_sections(unit)
|
||||
return unit
|
||||
|
||||
|
||||
def _resolve_training_unit_section_id(cur, unit_id: int, section_order_index: int) -> int:
|
||||
"""Erste Sektion mit order_index in einer whole_group-Phase (Parallelstreams ausgeschlossen)."""
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id FROM training_unit_sections
|
||||
WHERE training_unit_id = %s AND order_index = %s
|
||||
SELECT tus.id
|
||||
FROM training_unit_sections tus
|
||||
INNER JOIN training_unit_phases p ON p.id = tus.phase_id
|
||||
WHERE tus.training_unit_id = %s
|
||||
AND tus.order_index = %s
|
||||
AND tus.parallel_stream_id IS NULL
|
||||
AND LOWER(TRIM(p.phase_kind)) = 'whole_group'
|
||||
ORDER BY p.order_index ASC, tus.id ASC
|
||||
LIMIT 1
|
||||
""",
|
||||
(unit_id, section_order_index),
|
||||
)
|
||||
|
|
@ -886,8 +1004,20 @@ def _insert_section_items(cur, section_id: int, items_in: Optional[List[Any]], s
|
|||
)
|
||||
|
||||
|
||||
def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_index: int) -> None:
|
||||
"""Eine Sektion inkl. Items einfügen (Ersetzungsbaum; keine Löschlogik)."""
|
||||
def _insert_one_replacement_section(
|
||||
cur,
|
||||
unit_id: int,
|
||||
sec: Any,
|
||||
enumeration_index: int,
|
||||
*,
|
||||
phase_id: Optional[int] = None,
|
||||
parallel_stream_id: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Eine Sektion inkl. Items (genau eines von phase_id / parallel_stream_id gesetzt)."""
|
||||
if (phase_id is None) == (parallel_stream_id is None):
|
||||
raise HTTPException(
|
||||
status_code=500, detail="Intern: Sektion braucht phase_id oder parallel_stream_id"
|
||||
)
|
||||
title = (sec.get("title") or "").strip() or "Abschnitt"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
|
|
@ -896,12 +1026,14 @@ def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_ind
|
|||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s)
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
RETURNING id
|
||||
""",
|
||||
(
|
||||
unit_id,
|
||||
phase_id,
|
||||
parallel_stream_id,
|
||||
order_ix,
|
||||
title,
|
||||
sec.get("guidance_notes"),
|
||||
|
|
@ -913,10 +1045,13 @@ def _insert_one_replacement_section(cur, unit_id: int, sec: Any, enumeration_ind
|
|||
|
||||
|
||||
def _replace_unit_sections(cur, unit_id: int, sections_in: List[Any]):
|
||||
"""Ersetzt den gesamten Sektionsbaum der Einheit (DELETE aller Sektionen + Neuaufbau)."""
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
"""Ersetzt den gesamten Plan (Legacy): eine whole_group-Phase + Sektionen."""
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
phase_id = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
for si, sec in enumerate(sections_in):
|
||||
_insert_one_replacement_section(cur, unit_id, sec, si)
|
||||
_insert_one_replacement_section(
|
||||
cur, unit_id, sec, si, phase_id=phase_id, parallel_stream_id=None
|
||||
)
|
||||
|
||||
|
||||
def _distinct_exercise_ids_in_unit(cur, unit_id: int) -> List[int]:
|
||||
|
|
@ -1045,13 +1180,16 @@ def _promote_private_exercises_used_in_unit(cur, unit_id: int, profile_id: int,
|
|||
def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List[Any]):
|
||||
if not exercises_in:
|
||||
return
|
||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||||
VALUES (%s, 0, %s, NULL)
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
|
||||
)
|
||||
VALUES (%s, %s, NULL, 0, %s, NULL)
|
||||
RETURNING id
|
||||
""",
|
||||
(unit_id, "Übungen"),
|
||||
(unit_id, pid, "Übungen"),
|
||||
)
|
||||
sid = cur.fetchone()["id"]
|
||||
slot = 0
|
||||
|
|
@ -1080,6 +1218,8 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
|
|||
|
||||
|
||||
def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, guidance_text
|
||||
|
|
@ -1090,29 +1230,26 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
|||
(template_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for row in rows:
|
||||
for gi, row in enumerate(rows):
|
||||
r = r2d(row)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, (
|
||||
SELECT COALESCE(MAX(order_index), -1) + 1 FROM training_unit_sections u2
|
||||
WHERE u2.training_unit_id = %s
|
||||
), %s, %s, %s)
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes, source_template_section_id
|
||||
) VALUES (%s, %s, NULL, %s, %s, %s, %s)
|
||||
""",
|
||||
(unit_id, unit_id, r["title"], r["guidance_text"], r["id"]),
|
||||
(unit_id, pid, gi, r["title"], r["guidance_text"], r["id"]),
|
||||
)
|
||||
|
||||
# Fallback: keine Sektionen in Vorlage → ein leerer Block
|
||||
if not rows:
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (training_unit_id, order_index, title, guidance_notes)
|
||||
SELECT %s, 0, %s, NULL
|
||||
WHERE NOT EXISTS (SELECT 1 FROM training_unit_sections WHERE training_unit_id = %s)
|
||||
INSERT INTO training_unit_sections (
|
||||
training_unit_id, phase_id, parallel_stream_id, order_index, title, guidance_notes
|
||||
) VALUES (%s, %s, NULL, 0, 'Hauptteil', NULL)
|
||||
""",
|
||||
(unit_id, "Hauptteil", unit_id),
|
||||
(unit_id, pid),
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -2090,7 +2227,6 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
detail="reset_from_template erfordert plan_template_id auf der Einheit oder im Request",
|
||||
)
|
||||
_template_access(cur, tid, profile_id, role)
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
cur.execute(
|
||||
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
||||
)
|
||||
|
|
@ -2100,7 +2236,7 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
if not content_handled and "sections" in data:
|
||||
_replace_unit_sections(cur, unit_id, data["sections"] or [])
|
||||
elif not content_handled and "exercises" in data:
|
||||
cur.execute("DELETE FROM training_unit_sections WHERE training_unit_id = %s", (unit_id,))
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
_insert_sections_from_legacy_exercises(cur, unit_id, data["exercises"] or [])
|
||||
|
||||
if content_handled or "sections" in data or "exercises" in data:
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.136"
|
||||
APP_VERSION = "0.8.137"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260514062"
|
||||
DB_SCHEMA_VERSION = "20260515063"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"legal_documents": "1.4.0", # Admin: Live-Vorschau pro Abschnitt + modale Vollvorschau (Editor + Dokumentenliste)
|
||||
|
|
@ -24,7 +24,7 @@ MODULE_VERSIONS = {
|
|||
"exercises": "2.28.0", # GET /api/exercises Keyset cursor_updated_at + cursor_id; Sortierung id als Tie-break
|
||||
"training_units": "0.3.0", # GET /api/training-units Keyset cursor_planned_date + cursor_id (+ optional cursor_planned_time); Sort mit id-Tiebreak
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.9.5", # assistant_trainer_profile_ids: JSONB-Write mit PsycopgJson (Fix 500 bei Co-Liste)
|
||||
"planning": "0.10.0", # Migration 063: training_unit_phases + parallel_streams; Sektionen phase_id|parallel_stream_id; GET phases + sections
|
||||
"dashboard": "1.1.0", # GET /api/dashboard/kpis inkl. training_home (ein Client-Roundtrip für KPIs + nächste Termine)
|
||||
"training_modules": "1.0.0",
|
||||
"import_wiki": "1.0.0",
|
||||
|
|
@ -36,6 +36,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.137",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"DB 063: training_unit_phases, training_unit_parallel_streams; Sektionen mit phase_id oder parallel_stream_id; Default whole_group für Bestand.",
|
||||
"Planung: GET training_unit liefert phases (verschachtelt) + sections (flach sortiert); Legacy-PUT sections weiterhin eine whole_group-Phase.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.136",
|
||||
"date": "2026-05-13",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user