feat: update version to 0.7.8 and enhance exercise variant handling
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m57s

- 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.
This commit is contained in:
Lars 2026-04-28 09:30:33 +02:00
parent 2c831d6cea
commit cf9f95377c
6 changed files with 190 additions and 39 deletions

View File

@ -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;

View File

@ -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

View File

@ -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()

View File

@ -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",

View File

@ -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() {
</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginBottom: '1rem' }}>
{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 (
<div key={idx} style={{
display: 'grid',
gridTemplateColumns: '30px 1fr 80px auto',
gridTemplateColumns: '30px minmax(0, 1fr) 80px auto',
gap: '0.5rem',
alignItems: 'center',
alignItems: 'start',
padding: '0.5rem',
background: 'var(--surface2)',
borderRadius: '8px'
}}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px' }}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '2px', paddingTop: '6px' }}>
<button
type="button"
onClick={() => moveExercise(idx, -1)}
@ -539,19 +560,47 @@ function TrainingPlanningPage() {
</button>
</div>
<select
className="form-input"
value={ex.exercise_id}
onChange={(e) => updateExercise(idx, 'exercise_id', parseInt(e.target.value))}
style={{ margin: 0 }}
>
<option value="">Übung wählen</option>
{exercises.map(exercise => (
<option key={exercise.id} value={exercise.id}>
{exercise.title}
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px', minWidth: 0 }}>
<select
className="form-input"
value={ex.exercise_id === '' || ex.exercise_id == null ? '' : String(ex.exercise_id)}
onChange={(e) => {
const raw = e.target.value
updateExercise(idx, 'exercise_id', raw === '' ? '' : parseInt(raw, 10))
}}
style={{ margin: 0 }}
>
<option value="">Übung wählen</option>
{exercises.map(exercise => (
<option key={exercise.id} value={exercise.id}>
{exercise.title}
</option>
))}
</select>
<select
className="form-input"
value={
ex.exercise_variant_id === '' || ex.exercise_variant_id == null
? ''
: String(ex.exercise_variant_id)
}
onChange={(e) => {
const raw = e.target.value
updateExercise(idx, 'exercise_variant_id', raw === '' ? '' : parseInt(raw, 10))
}}
disabled={!ex.exercise_id || variantOpts.length === 0}
style={{ margin: 0, fontSize: '0.875rem' }}
>
<option value="">
{variantOpts.length === 0 ? 'Keine Varianten hinterlegt' : 'Stammübung (ohne Variante)'}
</option>
))}
</select>
{variantOpts.map((v) => (
<option key={v.id} value={v.id}>
{v.variant_name || `Variante #${v.id}`}
</option>
))}
</select>
</div>
<input
type="number"
@ -576,7 +625,8 @@ function TrainingPlanningPage() {
</button>
</div>
))}
)
})}
</div>
)}

View File

@ -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) => {