Update version to 0.8.141 and enhance training plan template handling
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
All checks were successful
Deploy Development / deploy (push) Successful in 44s
Test Suite / pytest-backend (push) Successful in 37s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / k6 /health Baseline (push) Successful in 33s
Test Suite / playwright-tests (push) Successful in 1m13s
- Incremented app version to 0.8.141 and updated build date to 2026-05-14. - Modified the planning module version to 0.12.0, improving template section handling with phase metadata. - Introduced new functions for normalizing and inserting training plan template sections, ensuring accurate phase representation during saves. - Updated frontend components to utilize new utility functions for managing training plan templates, enhancing user experience and data integrity.
This commit is contained in:
parent
79e748b470
commit
c3eb5a62c4
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
8
backend/migrations/064_training_plan_template_phases.sql
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
-- Vorlagen: Phasen/Parallel-Streams wie im Einheiten-Editor (planLoc-Abbild)
|
||||
ALTER TABLE training_plan_template_sections
|
||||
ADD COLUMN IF NOT EXISTS phase_kind VARCHAR(20) NOT NULL DEFAULT 'whole_group',
|
||||
ADD COLUMN IF NOT EXISTS phase_order_index INT NOT NULL DEFAULT 0,
|
||||
ADD COLUMN IF NOT EXISTS parallel_stream_order_index INT NULL;
|
||||
|
||||
COMMENT ON COLUMN training_plan_template_sections.parallel_stream_order_index IS
|
||||
'NULL = Ganzgruppen-Abschnitt; 0..n = Stream innerhalb paralleler Phase';
|
||||
|
|
@ -1523,32 +1523,187 @@ def _insert_sections_from_legacy_exercises(cur, unit_id: int, exercises_in: List
|
|||
_insert_section_items(cur, sid, filtered, start_order=0)
|
||||
|
||||
|
||||
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)
|
||||
def _normalize_training_plan_template_section_payload(sec: Any, si: int) -> Dict[str, Any]:
|
||||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
try:
|
||||
order_ix = int(order_ix)
|
||||
except (TypeError, ValueError):
|
||||
order_ix = si
|
||||
pk = str(sec.get("phase_kind") or "whole_group").strip().lower()
|
||||
if pk not in ("whole_group", "parallel"):
|
||||
pk = "whole_group"
|
||||
try:
|
||||
p_oi = int(sec.get("phase_order_index") if sec.get("phase_order_index") is not None else 0)
|
||||
except (TypeError, ValueError):
|
||||
p_oi = 0
|
||||
p_so: Optional[int] = None
|
||||
if pk == "parallel":
|
||||
raw_so = sec.get("parallel_stream_order_index")
|
||||
try:
|
||||
p_so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||
except (TypeError, ValueError):
|
||||
p_so = 0
|
||||
return {
|
||||
"title": title,
|
||||
"order_index": order_ix,
|
||||
"guidance_text": sec.get("guidance_text"),
|
||||
"phase_kind": pk,
|
||||
"phase_order_index": p_oi,
|
||||
"parallel_stream_order_index": p_so,
|
||||
}
|
||||
|
||||
|
||||
def _insert_training_plan_template_sections(cur, template_id: int, sections_in: List[Any]) -> None:
|
||||
for si, sec in enumerate(sections_in):
|
||||
row = _normalize_training_plan_template_section_payload(sec, si)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_plan_template_sections (
|
||||
template_id, order_index, title, guidance_text,
|
||||
phase_kind, phase_order_index, parallel_stream_order_index
|
||||
) VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||
""",
|
||||
(
|
||||
template_id,
|
||||
row["order_index"],
|
||||
row["title"],
|
||||
row["guidance_text"],
|
||||
row["phase_kind"],
|
||||
row["phase_order_index"],
|
||||
row["parallel_stream_order_index"],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def _template_rows_to_phases_payload(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
"""Flache Vorlagen-Sektionen → `phases`-Liste wie beim Training-Unit PUT (nur Gliederung, leere items)."""
|
||||
if not rows:
|
||||
return []
|
||||
phases_out: List[Dict[str, Any]] = []
|
||||
i = 0
|
||||
n = len(rows)
|
||||
while i < n:
|
||||
r0 = rows[i]
|
||||
pk0 = str(r0.get("phase_kind") or "whole_group").strip().lower()
|
||||
if pk0 not in ("whole_group", "parallel"):
|
||||
pk0 = "whole_group"
|
||||
try:
|
||||
p_oix0 = int(r0.get("phase_order_index") if r0.get("phase_order_index") is not None else 0)
|
||||
except (TypeError, ValueError):
|
||||
p_oix0 = 0
|
||||
run: List[Dict[str, Any]] = []
|
||||
while i < n:
|
||||
r = rows[i]
|
||||
pk = str(r.get("phase_kind") or "whole_group").strip().lower()
|
||||
if pk not in ("whole_group", "parallel"):
|
||||
pk = "whole_group"
|
||||
try:
|
||||
p_oix = int(r.get("phase_order_index") if r.get("phase_order_index") is not None else 0)
|
||||
except (TypeError, ValueError):
|
||||
p_oix = 0
|
||||
if pk != pk0 or p_oix != p_oix0:
|
||||
break
|
||||
run.append(r)
|
||||
i += 1
|
||||
if pk0 == "whole_group":
|
||||
secs = []
|
||||
for j, rr in enumerate(run):
|
||||
tid = rr.get("id")
|
||||
secs.append(
|
||||
{
|
||||
"title": rr.get("title"),
|
||||
"order_index": j,
|
||||
"guidance_notes": rr.get("guidance_text"),
|
||||
"items": [],
|
||||
**(
|
||||
{"source_template_section_id": int(tid)}
|
||||
if tid is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
phases_out.append(
|
||||
{
|
||||
"phase_kind": "whole_group",
|
||||
"order_index": p_oix0,
|
||||
"title": None,
|
||||
"guidance_notes": None,
|
||||
"sections": secs,
|
||||
}
|
||||
)
|
||||
else:
|
||||
by_stream: Dict[int, List[Dict[str, Any]]] = {}
|
||||
for rr in run:
|
||||
raw_so = rr.get("parallel_stream_order_index")
|
||||
try:
|
||||
so = int(raw_so) if raw_so is not None and raw_so != "" else 0
|
||||
except (TypeError, ValueError):
|
||||
so = 0
|
||||
by_stream.setdefault(so, []).append(rr)
|
||||
stream_order = sorted(by_stream.keys())
|
||||
streams = []
|
||||
for so in stream_order:
|
||||
bucket = by_stream[so]
|
||||
st: Dict[str, Any] = {
|
||||
"order_index": so,
|
||||
"title": None,
|
||||
"notes": None,
|
||||
"sections": [],
|
||||
}
|
||||
for j, rr in enumerate(bucket):
|
||||
tid = rr.get("id")
|
||||
st["sections"].append(
|
||||
{
|
||||
"title": rr.get("title"),
|
||||
"order_index": j,
|
||||
"guidance_notes": rr.get("guidance_text"),
|
||||
"items": [],
|
||||
**(
|
||||
{"source_template_section_id": int(tid)}
|
||||
if tid is not None
|
||||
else {}
|
||||
),
|
||||
}
|
||||
)
|
||||
streams.append(st)
|
||||
phases_out.append(
|
||||
{
|
||||
"phase_kind": "parallel",
|
||||
"order_index": p_oix0,
|
||||
"title": None,
|
||||
"guidance_notes": None,
|
||||
"streams": streams,
|
||||
}
|
||||
)
|
||||
return phases_out
|
||||
|
||||
|
||||
def _instantiate_from_template(
|
||||
cur,
|
||||
unit_id: int,
|
||||
template_id: int,
|
||||
*,
|
||||
profile_id: int,
|
||||
role: str,
|
||||
unit_created_by: int,
|
||||
) -> None:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT id, title, guidance_text
|
||||
SELECT id, title, guidance_text, order_index, phase_kind, phase_order_index, parallel_stream_order_index
|
||||
FROM training_plan_template_sections
|
||||
WHERE template_id = %s
|
||||
ORDER BY order_index
|
||||
""",
|
||||
(template_id,),
|
||||
)
|
||||
rows = cur.fetchall()
|
||||
for gi, row in enumerate(rows):
|
||||
r = r2d(row)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
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, pid, gi, r["title"], r["guidance_text"], r["id"]),
|
||||
)
|
||||
|
||||
# Fallback: keine Sektionen in Vorlage → ein leerer Block
|
||||
rows_raw = cur.fetchall()
|
||||
rows = [r2d(r) for r in rows_raw]
|
||||
if not rows:
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
pid = _ensure_default_whole_group_phase(cur, unit_id, order_index=0)
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_unit_sections (
|
||||
|
|
@ -1557,6 +1712,18 @@ def _instantiate_from_template(cur, unit_id: int, template_id: int):
|
|||
""",
|
||||
(unit_id, pid),
|
||||
)
|
||||
return
|
||||
|
||||
phases_payload = _template_rows_to_phases_payload(rows)
|
||||
_clear_unit_plan_content(cur, unit_id)
|
||||
_replace_unit_phases(
|
||||
cur,
|
||||
unit_id,
|
||||
phases_payload,
|
||||
profile_id,
|
||||
role,
|
||||
unit_created_by,
|
||||
)
|
||||
|
||||
|
||||
def _fetch_training_plan_template_row(cur, tid: int) -> Dict[str, Any]:
|
||||
|
|
@ -1674,18 +1841,7 @@ def create_training_plan_template(data: dict, tenant: TenantContext = Depends(ge
|
|||
(club_id, profile_id, name, data.get("description"), visibility),
|
||||
)
|
||||
tid = cur.fetchone()["id"]
|
||||
for si, sec in enumerate(sections_in):
|
||||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(tid, order_ix, title, sec.get("guidance_text")),
|
||||
)
|
||||
_insert_training_plan_template_sections(cur, tid, sections_in)
|
||||
conn.commit()
|
||||
return get_training_plan_template(tid, tenant)
|
||||
|
||||
|
|
@ -1743,18 +1899,7 @@ def update_training_plan_template(template_id: int, data: dict, tenant: TenantCo
|
|||
"DELETE FROM training_plan_template_sections WHERE template_id = %s", (template_id,)
|
||||
)
|
||||
sections_in = data["sections"] or []
|
||||
for si, sec in enumerate(sections_in):
|
||||
title = (sec.get("title") or "").strip() or f"Abschnitt {si + 1}"
|
||||
order_ix = sec.get("order_index")
|
||||
if order_ix is None:
|
||||
order_ix = si
|
||||
cur.execute(
|
||||
"""
|
||||
INSERT INTO training_plan_template_sections (template_id, order_index, title, guidance_text)
|
||||
VALUES (%s, %s, %s, %s)
|
||||
""",
|
||||
(template_id, order_ix, title, sec.get("guidance_text")),
|
||||
)
|
||||
_insert_training_plan_template_sections(cur, template_id, sections_in)
|
||||
conn.commit()
|
||||
return get_training_plan_template(template_id, tenant)
|
||||
|
||||
|
|
@ -2400,7 +2545,14 @@ def create_training_unit(data: dict, tenant: TenantContext = Depends(get_tenant_
|
|||
elif sections_in is not None:
|
||||
_replace_unit_sections(cur, unit_id, sections_in)
|
||||
elif tpl_id_safe:
|
||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||||
_instantiate_from_template(
|
||||
cur,
|
||||
unit_id,
|
||||
tpl_id_safe,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
unit_created_by=profile_id,
|
||||
)
|
||||
elif exercises_in is not None:
|
||||
_insert_sections_from_legacy_exercises(cur, unit_id, exercises_in)
|
||||
|
||||
|
|
@ -2576,7 +2728,14 @@ def update_training_unit(unit_id: int, data: dict, tenant: TenantContext = Depen
|
|||
cur.execute(
|
||||
"UPDATE training_units SET plan_template_id = %s WHERE id = %s", (tid, unit_id)
|
||||
)
|
||||
_instantiate_from_template(cur, unit_id, tid)
|
||||
_instantiate_from_template(
|
||||
cur,
|
||||
unit_id,
|
||||
tid,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
unit_created_by=int(unit_row.get("created_by") or profile_id),
|
||||
)
|
||||
content_handled = True
|
||||
|
||||
_assert_single_plan_content_key_update(data)
|
||||
|
|
@ -2783,7 +2942,14 @@ def quick_create_training_unit(data: dict, tenant: TenantContext = Depends(get_t
|
|||
unit_id = cur.fetchone()["id"]
|
||||
|
||||
if tpl_id_safe:
|
||||
_instantiate_from_template(cur, unit_id, tpl_id_safe)
|
||||
_instantiate_from_template(
|
||||
cur,
|
||||
unit_id,
|
||||
tpl_id_safe,
|
||||
profile_id=profile_id,
|
||||
role=role,
|
||||
unit_created_by=profile_id,
|
||||
)
|
||||
_promote_private_exercises_used_in_unit(cur, unit_id, profile_id, role)
|
||||
|
||||
conn.commit()
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.140"
|
||||
BUILD_DATE = "2026-05-12"
|
||||
DB_SCHEMA_VERSION = "20260515063"
|
||||
APP_VERSION = "0.8.141"
|
||||
BUILD_DATE = "2026-05-14"
|
||||
DB_SCHEMA_VERSION = "20260515064"
|
||||
|
||||
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.11.0", # PUT/POST training_units: phases (parallel streams); Rahmen→Termin-Kopie _replace_unit_phases; apply-training-module phase_order_index + parallel_stream_order_index
|
||||
"planning": "0.12.0", # Trainingsvorlagen: Phasen/Streams in template_sections (064); Instantiate über _replace_unit_phases
|
||||
"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,13 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.141",
|
||||
"date": "2026-05-14",
|
||||
"changes": [
|
||||
"DB 064: Vorlagen-Sektionen mit phase_kind / phase_order_index / parallel_stream_order_index; Speichern und Anwenden behält Split-Sessions; Server: Vorlage → Einheit über Phasen-Replace.",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.140",
|
||||
"date": "2026-05-14",
|
||||
|
|
|
|||
|
|
@ -19,6 +19,8 @@ import {
|
|||
buildPlanPayloadForSave,
|
||||
hydrateExercisePlanningRow,
|
||||
insertTrainingModuleIntoPlanningSections,
|
||||
templateSectionsPayloadFromFormSections,
|
||||
formSectionsFromPlanTemplateRows,
|
||||
} from '../../utils/trainingUnitSectionsForm'
|
||||
import {
|
||||
addDaysIsoDate,
|
||||
|
|
@ -549,12 +551,8 @@ function TrainingPlanningPageRoot() {
|
|||
setFormData((fd) => ({
|
||||
...fd,
|
||||
sections: (tpl.sections || []).length
|
||||
? tpl.sections.map((s) => ({
|
||||
title: s.title,
|
||||
guidance_notes: s.guidance_text || '',
|
||||
items: []
|
||||
}))
|
||||
: [defaultSection()]
|
||||
? formSectionsFromPlanTemplateRows(tpl.sections)
|
||||
: [defaultSection()],
|
||||
}))
|
||||
} catch (err) {
|
||||
toast.error('Vorlage laden: ' + err.message)
|
||||
|
|
@ -651,10 +649,7 @@ function TrainingPlanningPageRoot() {
|
|||
try {
|
||||
await api.createTrainingPlanTemplate({
|
||||
name: name.trim(),
|
||||
sections: formData.sections.map((s) => ({
|
||||
title: s.title || 'Abschnitt',
|
||||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null
|
||||
}))
|
||||
sections: templateSectionsPayloadFromFormSections(formData.sections),
|
||||
})
|
||||
await loadPlanTemplates()
|
||||
toast.success('Vorlage gespeichert.')
|
||||
|
|
|
|||
|
|
@ -1161,6 +1161,54 @@ export function buildPlanPayloadForSave(sections) {
|
|||
return buildPhasesPayloadFromFlat(list)
|
||||
}
|
||||
|
||||
/** Payload-Zeilen für POST/PUT /api/training-plan-templates (inkl. Split-/Phasen-Metadaten). */
|
||||
export function templateSectionsPayloadFromFormSections(sections) {
|
||||
const norm = inheritPlanLocForPhasedSave(Array.isArray(sections) ? sections : [])
|
||||
return norm.map((s, si) => {
|
||||
const canon = canonicalPlanLocForPhasedSave(s.planLoc)
|
||||
const pk = canon?.phaseKind === 'parallel' ? 'parallel' : 'whole_group'
|
||||
const poi = canon?.phaseOrderIndex ?? 0
|
||||
const pso = canon?.phaseKind === 'parallel' ? (canon.parallelStreamOrderIndex ?? 0) : null
|
||||
return {
|
||||
order_index: si,
|
||||
title: (s.title || '').trim() || `Abschnitt ${si + 1}`,
|
||||
guidance_text: s.guidance_notes?.trim() ? s.guidance_notes.trim() : null,
|
||||
phase_kind: pk,
|
||||
phase_order_index: poi,
|
||||
parallel_stream_order_index: pso,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** GET-Vorlage → Editor-Abschnitte mit planLoc (Split-Sessions). */
|
||||
export function formSectionsFromPlanTemplateRows(templateSections) {
|
||||
const rows = Array.isArray(templateSections) ? [...templateSections] : []
|
||||
rows.sort((a, b) => (a.order_index ?? 0) - (b.order_index ?? 0))
|
||||
if (!rows.length) return [defaultSection()]
|
||||
return rows.map((s) => {
|
||||
const pk = String(s.phase_kind || 'whole_group').toLowerCase().trim()
|
||||
const poiRaw = s.phase_order_index
|
||||
const poi = poiRaw == null || poiRaw === '' ? 0 : Number(poiRaw)
|
||||
const phaseOrderIndex = Number.isFinite(poi) ? poi : 0
|
||||
const soRaw = s.parallel_stream_order_index
|
||||
let planLoc
|
||||
if (pk === 'parallel') {
|
||||
const so = soRaw == null || soRaw === '' ? 0 : Number(soRaw)
|
||||
planLoc = {
|
||||
...defaultPlanLocParallel(phaseOrderIndex, Number.isFinite(so) ? so : 0),
|
||||
}
|
||||
} else {
|
||||
planLoc = { ...defaultPlanLocWholeGroup(phaseOrderIndex) }
|
||||
}
|
||||
return {
|
||||
title: s.title || 'Abschnitt',
|
||||
guidance_notes: s.guidance_text || '',
|
||||
items: [],
|
||||
planLoc,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/** Fügt die Positionen eines Moduls in lokale Abschnitte ein (wie eine Übung, ohne Zwischenspeichern der Einheit). */
|
||||
export async function insertTrainingModuleIntoPlanningSections({
|
||||
sections,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user