diff --git a/backend/migrations/030_training_unit_exercise_variant.sql b/backend/migrations/030_training_unit_exercise_variant.sql new file mode 100644 index 0000000..90c849c --- /dev/null +++ b/backend/migrations/030_training_unit_exercise_variant.sql @@ -0,0 +1,9 @@ +-- Migration 030: Übungsvariante in geplanten Trainingseinheiten +-- Nullable FK: keine Variante = Stammübung; bei Löschen der Variante bleibt die Zuordnung zur Übung erhalten + +ALTER TABLE training_unit_exercises + ADD COLUMN IF NOT EXISTS exercise_variant_id INT REFERENCES exercise_variants(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_training_unit_exercises_variant + ON training_unit_exercises(exercise_variant_id) + WHERE exercise_variant_id IS NOT NULL; diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 70b6642..53aad4b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -489,11 +489,16 @@ def list_exercises( ), limit: int = Query(default=50, ge=1, le=100), offset: int = Query(default=0, ge=0), + include_variants: bool = Query( + default=False, + description="Wenn true: Feld variants[] pro Übung (id, variant_name, sequence_order) für Planung/UI", + ), session: dict = Depends(require_auth), ): """ Liste aller Übungen mit Filtern. Lightweight Response (ohne M:N Details, nur IDs und Namen). + Optional include_variants für Variantenauswahl in der Trainingsplanung. """ profile_id = session["profile_id"] @@ -589,6 +594,25 @@ def list_exercises( where.append("e.search_vector @@ plainto_tsquery('german', %s)") params.append(qtext) + variants_sql = "" + if include_variants: + variants_sql = """, + ( + SELECT COALESCE( + json_agg( + json_build_object( + 'id', ev.id, + 'variant_name', ev.variant_name, + 'sequence_order', ev.sequence_order + ) + ORDER BY ev.sequence_order NULLS LAST, ev.id + ), + '[]'::json + ) + FROM exercise_variants ev + WHERE ev.exercise_id = e.id + ) AS variants""" + # Query (primary_focus_name für Listen-Ansicht gemäß Spec-Beispiel „focus_area“-Label) query = f""" SELECT e.id, e.title, e.summary, e.visibility, e.status, @@ -602,6 +626,7 @@ def list_exercises( ORDER BY efa.is_primary DESC NULLS LAST, fa.name ASC LIMIT 1 ) AS primary_focus_name + {variants_sql} FROM exercises e LEFT JOIN profiles p ON e.created_by = p.id LEFT JOIN clubs c ON e.club_id = c.id @@ -619,6 +644,15 @@ def list_exercises( d = r2d(r) pfn = d.get("primary_focus_name") d["focus_area"] = pfn + if include_variants: + v = d.get("variants") + if isinstance(v, str): + try: + d["variants"] = json.loads(v) + except Exception: + d["variants"] = [] + elif v is None: + d["variants"] = [] out.append(d) return out diff --git a/backend/routers/training_planning.py b/backend/routers/training_planning.py index c9882c9..54b2906 100644 --- a/backend/routers/training_planning.py +++ b/backend/routers/training_planning.py @@ -13,6 +13,32 @@ from auth import require_auth router = APIRouter(prefix="/api", tags=["training_planning"]) +def _optional_positive_int(val, field_name: str) -> Optional[int]: + if val is None or val == "": + return None + try: + i = int(val) + except (TypeError, ValueError): + raise HTTPException(400, detail=f"Ungültige {field_name}") + if i < 1: + raise HTTPException(400, detail=f"Ungültige {field_name}") + return i + + +def _validate_variant_for_exercise(cur, exercise_id: Optional[int], variant_id: Optional[int]): + """Prüft, dass exercise_variant_id zur gewählten Übung gehört.""" + if not variant_id: + return + if not exercise_id: + raise HTTPException(400, detail="exercise_variant_id nur zusammen mit exercise_id erlaubt") + cur.execute( + "SELECT 1 FROM exercise_variants WHERE id = %s AND exercise_id = %s", + (variant_id, exercise_id), + ) + if not cur.fetchone(): + raise HTTPException(400, detail="Variante passt nicht zur gewählten Übung") + + # ── List Training Units ─────────────────────────────────────────────── @router.get("/training-units") def list_training_units( @@ -130,9 +156,11 @@ def get_training_unit(unit_id: int, session=Depends(require_auth)): SELECT tue.*, e.title as exercise_title, e.summary as exercise_summary, - e.focus_area as exercise_focus_area + e.focus_area as exercise_focus_area, + ev.variant_name as exercise_variant_name FROM training_unit_exercises tue LEFT JOIN exercises e ON tue.exercise_id = e.id + LEFT JOIN exercise_variants ev ON tue.exercise_variant_id = ev.id WHERE tue.training_unit_id = %s ORDER BY tue.order_index """, (unit_id,)) @@ -198,21 +226,29 @@ def create_training_unit(data: dict, session=Depends(require_auth)): unit_id = cur.fetchone()['id'] - # Add exercises if provided - exercises = data.get('exercises', []) - for idx, ex in enumerate(exercises): + exercises_in = data.get('exercises', []) + slot = 0 + for ex in exercises_in: + eid = ex.get('exercise_id') + if not eid: + continue + eid = int(eid) + vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') + _validate_variant_for_exercise(cur, eid, vid) cur.execute(""" INSERT INTO training_unit_exercises ( - training_unit_id, exercise_id, order_index, + training_unit_id, exercise_id, exercise_variant_id, order_index, planned_duration_min, notes - ) VALUES (%s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s) """, ( unit_id, - ex.get('exercise_id'), - idx, + eid, + vid, + slot, ex.get('planned_duration_min'), ex.get('notes') )) + slot += 1 conn.commit() @@ -285,23 +321,32 @@ def update_training_unit(unit_id: int, data: dict, session=Depends(require_auth) cur.execute("DELETE FROM training_unit_exercises WHERE training_unit_id = %s", (unit_id,)) # Add new exercises - exercises = data['exercises'] - for idx, ex in enumerate(exercises): + exercises_in = data['exercises'] + slot = 0 + for ex in exercises_in: + eid = ex.get('exercise_id') + if not eid: + continue + eid = int(eid) + vid = _optional_positive_int(ex.get('exercise_variant_id'), 'exercise_variant_id') + _validate_variant_for_exercise(cur, eid, vid) cur.execute(""" INSERT INTO training_unit_exercises ( - training_unit_id, exercise_id, order_index, + training_unit_id, exercise_id, exercise_variant_id, order_index, planned_duration_min, actual_duration_min, notes, modifications - ) VALUES (%s, %s, %s, %s, %s, %s, %s) + ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s) """, ( unit_id, - ex.get('exercise_id'), - idx, + eid, + vid, + slot, ex.get('planned_duration_min'), ex.get('actual_duration_min'), ex.get('notes'), ex.get('modifications') )) + slot += 1 conn.commit() diff --git a/backend/version.py b/backend/version.py index 83bcc74..55b0216 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.7.7" +APP_VERSION = "0.7.8" BUILD_DATE = "2026-04-27" -DB_SCHEMA_VERSION = "20260427029" +DB_SCHEMA_VERSION = "20260427030" MODULE_VERSIONS = { "auth": "1.0.0", @@ -14,7 +14,7 @@ MODULE_VERSIONS = { "exercises": "2.0.0", # BREAKING: Clean-Room Rebuild, Legacy-Felder entfernt, nur M:N "training_units": "0.1.0", "training_programs": "0.1.0", - "planning": "0.1.0", + "planning": "0.2.0", "import_wiki": "1.0.0", "admin": "1.0.0", "membership": "1.0.0", @@ -23,6 +23,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.7.8", + "date": "2026-04-27", + "changes": [ + "DB 030: training_unit_exercises.exercise_variant_id (FK exercise_variants)", + "GET /exercises?include_variants=true liefert Varianten für Trainingsplanung", + "Trainingseinheiten: optional exercise_variant_id beim Anlegen/Aktualisieren", + ], + }, { "version": "0.7.7", "date": "2026-04-27", diff --git a/frontend/src/pages/TrainingPlanningPage.jsx b/frontend/src/pages/TrainingPlanningPage.jsx index 4ac5f1a..e447f3a 100644 --- a/frontend/src/pages/TrainingPlanningPage.jsx +++ b/frontend/src/pages/TrainingPlanningPage.jsx @@ -50,7 +50,7 @@ function TrainingPlanningPage() { try { const [groupsData, exercisesData] = await Promise.all([ api.listTrainingGroups({ status: 'active' }), - api.listExercises() + api.listExercises({ include_variants: true }) ]) setGroups(groupsData) setExercises(exercisesData) @@ -179,9 +179,20 @@ function TrainingPlanningPage() { ...formData, group_id: parseInt(formData.group_id), attendance_count: formData.attendance_count ? parseInt(formData.attendance_count) : null, - exercises: formData.exercises.map((ex, idx) => ({ + exercises: formData.exercises + .filter( + (ex) => + ex.exercise_id !== '' && + ex.exercise_id != null && + !Number.isNaN(Number(ex.exercise_id)) + ) + .map((ex, idx) => ({ exercise_id: ex.exercise_id, order_index: idx, + exercise_variant_id: + ex.exercise_variant_id !== '' && ex.exercise_variant_id != null + ? parseInt(ex.exercise_variant_id, 10) + : null, planned_duration_min: ex.planned_duration_min ? parseInt(ex.planned_duration_min) : null, actual_duration_min: ex.actual_duration_min ? parseInt(ex.actual_duration_min) : null, notes: ex.notes || null, @@ -211,6 +222,7 @@ function TrainingPlanningPage() { ...prev, exercises: [...prev.exercises, { exercise_id: '', + exercise_variant_id: '', planned_duration_min: '', actual_duration_min: '', notes: '', @@ -222,9 +234,14 @@ function TrainingPlanningPage() { const updateExercise = (index, field, value) => { setFormData(prev => ({ ...prev, - exercises: prev.exercises.map((ex, i) => - i === index ? { ...ex, [field]: value } : ex - ) + exercises: prev.exercises.map((ex, i) => { + if (i !== index) return ex + const next = { ...ex, [field]: value } + if (field === 'exercise_id') { + next.exercise_variant_id = '' + } + return next + }) })) } @@ -496,17 +513,21 @@ function TrainingPlanningPage() {
) : (