From 1ee1a2f2d934408b098362ed8c7ecfd383f909f8 Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 28 Apr 2026 10:59:09 +0200 Subject: [PATCH] feat: update version to 0.7.9 and enhance exercise variant management - Incremented application version to 0.7.9 and updated changelog to reflect new features. - Added support for creating, updating, and deleting exercise variants via new API endpoints. - Implemented functionality for reordering exercise variants, improving user experience in managing exercise options. - Enhanced frontend components to display and manage exercise variants, including detailed information and editing capabilities. --- backend/routers/exercises.py | 285 ++++++++++++- backend/version.py | 14 +- frontend/src/pages/ExerciseDetailPage.jsx | 67 +++- frontend/src/pages/ExerciseFormPage.jsx | 468 +++++++++++++++++++++- frontend/src/utils/api.js | 29 ++ 5 files changed, 837 insertions(+), 26 deletions(-) diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 53aad4b..d4a04b3 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -167,6 +167,36 @@ class ExerciseMediaReorder(BaseModel): media_ids: list[int] +class ExerciseVariantCreate(BaseModel): + variant_name: str = Field(..., min_length=3, max_length=200) + description: Optional[str] = None + execution_changes: Optional[str] = None + duration_min: Optional[int] = None + duration_max: Optional[int] = None + equipment_changes: Optional[list[str]] = None + difficulty_adjustment: Optional[str] = Field(None, max_length=50) + progression_level: int = Field(default=1, ge=1, le=10) + sequence_order: Optional[int] = None + prerequisite_variant_id: Optional[int] = None + + +class ExerciseVariantUpdate(BaseModel): + variant_name: Optional[str] = Field(None, min_length=3, max_length=200) + description: Optional[str] = None + execution_changes: Optional[str] = None + duration_min: Optional[int] = None + duration_max: Optional[int] = None + equipment_changes: Optional[list[str]] = None + difficulty_adjustment: Optional[str] = Field(None, max_length=50) + progression_level: Optional[int] = Field(None, ge=1, le=10) + sequence_order: Optional[int] = None + prerequisite_variant_id: Optional[int] = None + + +class ExerciseVariantsReorder(BaseModel): + variant_ids: list[int] + + # ============================================================================ # Helper Functions # ============================================================================ @@ -209,6 +239,48 @@ def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int): raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten") +def _variant_equipment_json(changes: Optional[list]) -> str: + return json.dumps(changes if changes else []) + + +def _normalize_variant_equipment_list(val) -> list: + if val is None: + return [] + if isinstance(val, list): + return val + if isinstance(val, str): + try: + return json.loads(val) + except Exception: + return [] + return [] + + +def _validate_variant_prerequisite(cur, exercise_id: int, prereq_id: Optional[int]) -> None: + if prereq_id is None: + return + cur.execute( + "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", + (prereq_id, exercise_id), + ) + if not cur.fetchone(): + raise HTTPException(status_code=400, detail="Voraussetzungs-Variante gehört nicht zu dieser Übung") + + +def _fetch_variant_row(cur, exercise_id: int, variant_id: int) -> dict: + cur.execute( + """SELECT id, variant_name, description, execution_changes, + duration_min, duration_max, equipment_changes, difficulty_adjustment, + progression_level, sequence_order, prerequisite_variant_id, created_at + FROM exercise_variants WHERE id = %s AND exercise_id = %s""", + (variant_id, exercise_id), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Variante nicht gefunden") + return r2d(row) + + def _count_exercise_media(cur, exercise_id: int) -> int: cur.execute("SELECT COUNT(*) AS c FROM exercise_media WHERE exercise_id = %s", (exercise_id,)) r = cur.fetchone() @@ -323,14 +395,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: sk["required_level"] = normalize_exercise_skill_level(sk.get("required_level")) sk["target_level"] = normalize_exercise_skill_level(sk.get("target_level")) - # Variants (1:N) - mit Progression + # Variants (1:N) - mit Progression (Reihenfolge: sequence_order, dann progression_level) cur.execute( """SELECT id, variant_name, description, execution_changes, duration_min, duration_max, equipment_changes, difficulty_adjustment, - progression_level, sequence_order, prerequisite_variant_id + progression_level, sequence_order, prerequisite_variant_id, created_at FROM exercise_variants WHERE exercise_id = %s - ORDER BY progression_level, sequence_order""", + ORDER BY sequence_order NULLS LAST, progression_level, id""", (exercise_id,) ) exercise["variants"] = [r2d(r) for r in cur.fetchall()] @@ -605,7 +677,7 @@ def list_exercises( 'variant_name', ev.variant_name, 'sequence_order', ev.sequence_order ) - ORDER BY ev.sequence_order NULLS LAST, ev.id + ORDER BY ev.sequence_order NULLS LAST, ev.progression_level, ev.id ), '[]'::json ) @@ -840,6 +912,211 @@ def delete_exercise( return {"ok": True} +# --- Übungsvarianten (EXERCISES_API_SPEC.md) --- + + +@router.put("/exercises/{exercise_id}/variants/reorder") +def reorder_exercise_variants( + exercise_id: int, + body: ExerciseVariantsReorder, + session: dict = Depends(require_auth), +): + profile_id = session["profile_id"] + + if len(body.variant_ids) != len(set(body.variant_ids)): + raise HTTPException(status_code=400, detail="variant_ids dürfen keine Duplikate enthalten") + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, profile_id) + + cur.execute( + "SELECT id FROM exercise_variants WHERE exercise_id = %s", + (exercise_id,), + ) + existing = [r["id"] for r in cur.fetchall()] + if set(existing) != set(body.variant_ids): + raise HTTPException( + status_code=400, + detail="variant_ids müssen alle Varianten dieser Übung genau einmal enthalten", + ) + + for pos, vid in enumerate(body.variant_ids): + cur.execute( + """UPDATE exercise_variants SET sequence_order = %s + WHERE id = %s AND exercise_id = %s""", + (pos + 1, vid, exercise_id), + ) + conn.commit() + + return {"ok": True, "reordered": len(body.variant_ids)} + + +@router.post("/exercises/{exercise_id}/variants", status_code=201) +def create_exercise_variant( + exercise_id: int, + body: ExerciseVariantCreate, + session: dict = Depends(require_auth), +): + profile_id = session["profile_id"] + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, profile_id) + _validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id) + + eq_json = _variant_equipment_json(body.equipment_changes) + seq = body.sequence_order + if seq is None: + cur.execute( + """SELECT COALESCE(MAX(sequence_order), 0) + 1 AS n + FROM exercise_variants WHERE exercise_id = %s""", + (exercise_id,), + ) + seq = cur.fetchone()["n"] + + desc = (body.description or "").strip() or None + exec_ch = (body.execution_changes or "").strip() or None + diff = (body.difficulty_adjustment or "").strip() or None + + cur.execute( + """INSERT INTO exercise_variants ( + exercise_id, variant_name, description, execution_changes, + duration_min, duration_max, equipment_changes, difficulty_adjustment, + progression_level, sequence_order, prerequisite_variant_id + ) VALUES (%s,%s,%s,%s,%s,%s,%s::jsonb,%s,%s,%s,%s) + RETURNING id""", + ( + exercise_id, + body.variant_name.strip(), + desc, + exec_ch, + body.duration_min, + body.duration_max, + eq_json, + diff, + body.progression_level, + seq, + body.prerequisite_variant_id, + ), + ) + new_id = cur.fetchone()["id"] + row = _fetch_variant_row(cur, exercise_id, new_id) + conn.commit() + + return row + + +@router.put("/exercises/{exercise_id}/variants/{variant_id}") +def update_exercise_variant( + exercise_id: int, + variant_id: int, + body: ExerciseVariantUpdate, + session: dict = Depends(require_auth), +): + profile_id = session["profile_id"] + + data = body.dict(exclude_unset=True) + if not data: + raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, profile_id) + old = _fetch_variant_row(cur, exercise_id, variant_id) + + if "variant_name" in data and data["variant_name"] is not None: + old["variant_name"] = data["variant_name"].strip() + if "description" in data: + old["description"] = (data["description"] or "").strip() or None + if "execution_changes" in data: + old["execution_changes"] = (data["execution_changes"] or "").strip() or None + if "duration_min" in data: + old["duration_min"] = data["duration_min"] + if "duration_max" in data: + old["duration_max"] = data["duration_max"] + if "equipment_changes" in data: + old["equipment_changes"] = _normalize_variant_equipment_list(data["equipment_changes"]) + if "difficulty_adjustment" in data: + old["difficulty_adjustment"] = (data["difficulty_adjustment"] or "").strip() or None + if "progression_level" in data and data["progression_level"] is not None: + old["progression_level"] = data["progression_level"] + if "sequence_order" in data: + old["sequence_order"] = data["sequence_order"] + if "prerequisite_variant_id" in data: + old["prerequisite_variant_id"] = data["prerequisite_variant_id"] + + prereq = old.get("prerequisite_variant_id") + if prereq == variant_id: + raise HTTPException(status_code=400, detail="Variante kann nicht ihre eigene Voraussetzung sein") + _validate_variant_prerequisite(cur, exercise_id, prereq) + + eq_db = _variant_equipment_json(_normalize_variant_equipment_list(old.get("equipment_changes"))) + + cur.execute( + """UPDATE exercise_variants SET + variant_name = %s, + description = %s, + execution_changes = %s, + duration_min = %s, + duration_max = %s, + equipment_changes = %s::jsonb, + difficulty_adjustment = %s, + progression_level = %s, + sequence_order = %s, + prerequisite_variant_id = %s + WHERE id = %s AND exercise_id = %s""", + ( + old["variant_name"], + old.get("description"), + old.get("execution_changes"), + old.get("duration_min"), + old.get("duration_max"), + eq_db, + old.get("difficulty_adjustment"), + old.get("progression_level"), + old.get("sequence_order"), + old.get("prerequisite_variant_id"), + variant_id, + exercise_id, + ), + ) + row = _fetch_variant_row(cur, exercise_id, variant_id) + conn.commit() + + return row +def delete_exercise_variant( + exercise_id: int, + variant_id: int, + session: dict = Depends(require_auth), +): + profile_id = session["profile_id"] + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, profile_id) + _fetch_variant_row(cur, exercise_id, variant_id) + + cur.execute( + "SELECT COUNT(*) AS c FROM exercise_variants WHERE prerequisite_variant_id = %s", + (variant_id,), + ) + cnt = int(cur.fetchone()["c"]) + if cnt > 0: + raise HTTPException( + status_code=409, + detail="Variante ist Voraussetzung anderer Varianten — zuerst dort ändern oder entfernen", + ) + + cur.execute( + "DELETE FROM exercise_variants WHERE id = %s AND exercise_id = %s", + (variant_id, exercise_id), + ) + conn.commit() + + return {"ok": True} + + # --- Medien (MEDIA_UPLOAD_SPEC.md / EXERCISES_API_SPEC.md) --- diff --git a/backend/version.py b/backend/version.py index 55b0216..015ecb5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.8" +APP_VERSION = "0.7.9" BUILD_DATE = "2026-04-27" DB_SCHEMA_VERSION = "20260427030" @@ -11,7 +11,7 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.0.0", # BREAKING: Clean-Room Rebuild, Legacy-Felder entfernt, nur M:N + "exercises": "2.1.0", # Varianten-CRUD API + UI; Listen mit include_variants "training_units": "0.1.0", "training_programs": "0.1.0", "planning": "0.2.0", @@ -23,6 +23,16 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.7.9", + "date": "2026-04-27", + "changes": [ + "Übungsvarianten: POST/PUT/DELETE /api/exercises/{id}/variants + reorder", + "Übung bearbeiten: voller Varianten-Editor (Speichern pro Variante, Reihenfolge, Voraussetzung)", + "Übung Ansehen: Varianten-Metadaten (Dauer, Schwierigkeit, Material, Progression)", + "GET /exercises Detail: Varianten-Sortierung sequence_order → progression_level", + ], + }, { "version": "0.7.8", "date": "2026-04-27", diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index ace9823..ddf901c 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -263,24 +263,55 @@ function ExerciseDetailPage() { {(exercise.variants || []).length > 0 && (

Varianten

- {exercise.variants.map((v) => ( -
- {v.variant_name} - {v.description &&

{v.description}

} - {v.execution_changes && ( -
- -
- )} -
- ))} + {exercise.variants.map((v) => { + const dur = + v.duration_min != null || v.duration_max != null + ? v.duration_min != null && v.duration_max != null && v.duration_min !== v.duration_max + ? `${v.duration_min}–${v.duration_max} Min.` + : v.duration_min != null + ? `ca. ${v.duration_min} Min.` + : `bis ca. ${v.duration_max} Min.` + : null + const diffLabel = + v.difficulty_adjustment === 'easier' + ? 'einfacher' + : v.difficulty_adjustment === 'harder' + ? 'schwerer' + : v.difficulty_adjustment === 'same' + ? 'gleiche Schwierigkeit' + : v.difficulty_adjustment === 'adapted' + ? 'angepasst' + : null + const equip = + Array.isArray(v.equipment_changes) && v.equipment_changes.length > 0 + ? v.equipment_changes.join(', ') + : null + return ( +
+ {v.variant_name} + {(dur || diffLabel || equip || v.progression_level != null) && ( +
+ {[dur, diffLabel, equip && `Material: ${equip}`, v.progression_level != null && `Progression ${v.progression_level}`] + .filter(Boolean) + .join(' · ')} +
+ )} + {v.description &&

{v.description}

} + {v.execution_changes && ( +
+ +
+ )} +
+ ) + })}
)} diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 10a46ea..2c29274 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -11,6 +11,82 @@ const INTENSITY_OPTIONS = [ { value: 'hoch', label: 'hoch' }, ] +const VARIANT_DIFFICULTY = [ + { value: '', label: '—' }, + { value: 'easier', label: 'Einfacher' }, + { value: 'same', label: 'Gleich' }, + { value: 'harder', label: 'Schwerer' }, + { value: 'adapted', label: 'Angepasst' }, +] + +function emptyVariantDraft() { + return { + variant_name: '', + description: '', + execution_changes: '', + duration_min: '', + duration_max: '', + equipment_lines: '', + difficulty_adjustment: '', + progression_level: 1, + prerequisite_variant_id: '', + } +} + +function apiVariantToRow(v) { + let lines = '' + const eq = v.equipment_changes + if (Array.isArray(eq)) { + lines = eq.join('\n') + } else if (typeof eq === 'string' && eq.trim()) { + try { + const p = JSON.parse(eq) + lines = Array.isArray(p) ? p.join('\n') : eq + } catch { + lines = eq + } + } + return { + ...v, + duration_min: v.duration_min ?? '', + duration_max: v.duration_max ?? '', + equipment_lines: lines, + progression_level: v.progression_level ?? 1, + prerequisite_variant_id: v.prerequisite_variant_id ?? '', + difficulty_adjustment: v.difficulty_adjustment ?? '', + } +} + +function buildVariantPayloadFromRow(row) { + const lines = (row.equipment_lines || '') + .split(/[\n,]+/) + .map((s) => s.trim()) + .filter(Boolean) + const pl = + row.progression_level === '' || row.progression_level == null + ? 1 + : parseInt(row.progression_level, 10) + const so = + row.sequence_order === '' || row.sequence_order == null + ? null + : parseInt(row.sequence_order, 10) + return { + variant_name: (row.variant_name || '').trim(), + description: (row.description || '').trim() || null, + execution_changes: (row.execution_changes || '').trim() || null, + duration_min: row.duration_min === '' || row.duration_min == null ? null : parseInt(row.duration_min, 10), + duration_max: row.duration_max === '' || row.duration_max == null ? null : parseInt(row.duration_max, 10), + equipment_changes: lines, + difficulty_adjustment: row.difficulty_adjustment || null, + progression_level: Number.isNaN(pl) ? 1 : pl, + sequence_order: so !== null && Number.isNaN(so) ? null : so, + prerequisite_variant_id: + row.prerequisite_variant_id === '' || row.prerequisite_variant_id == null + ? null + : parseInt(row.prerequisite_variant_id, 10), + } +} + function emptyForm() { return { title: '', @@ -157,6 +233,10 @@ function ExerciseFormPage() { const [loading, setLoading] = useState(!!isEdit) const [saving, setSaving] = useState(false) const [skillPick, setSkillPick] = useState('') + const [variants, setVariants] = useState([]) + const [variantDraft, setVariantDraft] = useState(() => emptyVariantDraft()) + const [variantSavingId, setVariantSavingId] = useState(null) + const [variantBusy, setVariantBusy] = useState(false) const [mediaFile, setMediaFile] = useState(null) const [mediaType, setMediaType] = useState('image') @@ -202,6 +282,8 @@ function ExerciseFormPage() { if (!isEdit) { setFormData(emptyForm()) setMediaList([]) + setVariants([]) + setVariantDraft(emptyVariantDraft()) setLoading(false) return } @@ -213,6 +295,8 @@ function ExerciseFormPage() { if (cancelled) return setFormData(detailToForm(exercise)) setMediaList(exercise.media || []) + setVariants((exercise.variants || []).map(apiVariantToRow)) + setVariantDraft(emptyVariantDraft()) } catch (err) { if (!cancelled) { alert(err.message || 'Übung nicht ladbar') @@ -304,6 +388,7 @@ function ExerciseFormPage() { await api.updateExercise(exerciseId, payload) const ex = await api.getExercise(exerciseId) setMediaList(ex.media || []) + setVariants((ex.variants || []).map(apiVariantToRow)) alert('Gespeichert.') } else { const created = await api.createExercise(payload) @@ -376,6 +461,85 @@ function ExerciseFormPage() { } } + const refreshVariants = async () => { + if (!exerciseId) return + const ex = await api.getExercise(exerciseId) + setVariants((ex.variants || []).map(apiVariantToRow)) + } + + const updateVariantField = (id, patch) => { + setVariants((prev) => prev.map((v) => (v.id === id ? { ...v, ...patch } : v))) + } + + const saveVariantRow = async (row) => { + const payload = buildVariantPayloadFromRow(row) + if (payload.variant_name.length < 3) { + alert('Variantenname mindestens 3 Zeichen') + return + } + setVariantSavingId(row.id) + try { + await api.updateExerciseVariant(exerciseId, row.id, payload) + await refreshVariants() + } catch (e) { + alert(e.message || String(e)) + } finally { + setVariantSavingId(null) + } + } + + const deleteVariantRow = async (id) => { + if (!confirm('Variante wirklich löschen?')) return + setVariantBusy(true) + try { + await api.deleteExerciseVariant(exerciseId, id) + await refreshVariants() + } catch (e) { + alert(e.message || String(e)) + } finally { + setVariantBusy(false) + } + } + + const moveVariantRow = async (idx, dir) => { + const j = idx + dir + if (j < 0 || j >= variants.length) return + const next = [...variants] + const tmp = next[idx] + next[idx] = next[j] + next[j] = tmp + const ids = next.map((x) => x.id) + setVariantBusy(true) + try { + await api.reorderExerciseVariants(exerciseId, ids) + await refreshVariants() + } catch (e) { + alert(e.message || String(e)) + } finally { + setVariantBusy(false) + } + } + + const createVariantSubmit = async (e) => { + e.preventDefault() + if (!exerciseId) return + const payload = buildVariantPayloadFromRow(variantDraft) + if (payload.variant_name.length < 3) { + alert('Variantenname mindestens 3 Zeichen') + return + } + setVariantBusy(true) + try { + await api.createExerciseVariant(exerciseId, payload) + setVariantDraft(emptyVariantDraft()) + await refreshVariants() + } catch (err) { + alert(err.message || String(err)) + } finally { + setVariantBusy(false) + } + } + const availableSkills = skillsCatalog.filter((s) => !formData.skills.some((x) => x.skill_id === s.id)) if (loading) { @@ -689,6 +853,307 @@ function ExerciseFormPage() { + {isEdit && ( +
+

Übungsvarianten

+

+ Alternative Ausführungen zur Stammübung. Reihenfolge (Nach oben/unten) steuert Darstellung und Auswahl in der + Trainingsplanung. „Voraussetzung“ verknüpft Varianten für spätere Progressions-Serien als Blöcke. +

+ {variants.length === 0 && ( +

Noch keine Varianten angelegt.

+ )} + {variants.map((v, idx) => ( +
+
+ #{idx + 1} + + + + +
+
+ + updateVariantField(v.id, { variant_name: e.target.value })} + minLength={3} + /> +
+
+ +