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 && (
{v.description} {v.description}Varianten
- {exercise.variants.map((v) => (
-
+ 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) => ( +
- Varianten-Editor folgt später (API teilweise vorhanden).{' '}
KI-Ausbaustufe: Backend laut Spec{' '}
POST /api/exercises/ai/suggest und{' '}
- POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '}
+ POST /api/exercises/{'{id}'}/ai/regenerate — z. B.{' '}
OPENROUTER_API_KEY, Vorschläge nur nach Trainer-Bestätigung übernehmen (siehe{' '}
api.suggestExerciseAi).