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.
This commit is contained in:
parent
2c831d6cea
commit
cf9f95377c
|
|
@ -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;
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user