From cf9f95377c58949476cf5c1e8f93d666f00ead93 Mon Sep 17 00:00:00 2001
From: Lars
Date: Tue, 28 Apr 2026 09:30:33 +0200
Subject: [PATCH] feat: update version to 0.7.8 and enhance exercise variant
handling
- Incremented application version to 0.7.8 and updated database schema version to 20260427030.
- Added support for including exercise variants in the exercise listing API, improving training planning capabilities.
- Enhanced training unit creation and update logic to validate exercise variant IDs, ensuring proper associations.
- Updated frontend components to support exercise variant selection, improving user experience in training planning.
---
.../030_training_unit_exercise_variant.sql | 9 ++
backend/routers/exercises.py | 34 +++++++
backend/routers/training_planning.py | 73 +++++++++++---
backend/version.py | 15 ++-
frontend/src/pages/TrainingPlanningPage.jsx | 94 ++++++++++++++-----
frontend/src/utils/api.js | 4 +
6 files changed, 190 insertions(+), 39 deletions(-)
create mode 100644 backend/migrations/030_training_unit_exercise_variant.sql
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() {
) : (
- {formData.exercises.map((ex, idx) => (
+ {formData.exercises.map((ex, idx) => {
+ const picked = exercises.find((e) => e.id === ex.exercise_id)
+ const variantOpts = Array.isArray(picked?.variants) ? picked.variants : []
+
+ return (
-
+
-
- ))}
+ )
+ })}
)}
diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js
index fcc465d..abe339b 100644
--- a/frontend/src/utils/api.js
+++ b/frontend/src/utils/api.js
@@ -214,6 +214,10 @@ export async function listExercises(filters = {}) {
const q = new URLSearchParams()
Object.entries(filters).forEach(([k, v]) => {
if (v === undefined || v === null) return
+ if (typeof v === 'boolean') {
+ if (v) q.set(k, 'true')
+ return
+ }
if (Array.isArray(v)) {
if (v.length === 0) return
v.forEach((item) => {