shinkan-jinkendo/backend/routers/exercises.py
Lars d67f659e97
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 4s
Test Suite / playwright-tests (push) Failing after 1m57s
feat: Exercises-Router M:N Zuordnungen
Backend API erweitert um M:N Katalog-Zuordnungen:
- GET /exercises/{id}: Liefert focus_areas[], training_styles[], target_groups[], age_groups_catalog[]
- POST /exercises: Akzeptiert focus_areas_multi[], training_styles_multi[], target_groups_multi[], age_groups_catalog[]
- PUT /exercises/{id}: DELETE+INSERT Pattern für M:N Updates (konsistent mit skills)

Rückwärtskompatibilität:
- Legacy FK-Felder (focus_area_id, training_style_id, training_character_id) bleiben erhalten
- Alte Aufrufe funktionieren unverändert
- Neue M:N Felder sind optional

version: 0.3.1
modules: exercises 0.4.0
2026-04-23 08:51:45 +02:00

454 lines
17 KiB
Python

"""
Exercise Management Endpoints for Shinkan Jinkendo
Handles CRUD operations for exercises (Übungen).
"""
from typing import Optional
from fastapi import APIRouter, HTTPException, Depends, Query
from db import get_db, get_cursor, r2d
from auth import require_auth
router = APIRouter(prefix="/api", tags=["exercises"])
# ── List Exercises ────────────────────────────────────────────────────────
@router.get("/exercises")
def list_exercises(
focus_area: Optional[str] = Query(default=None),
visibility: Optional[str] = Query(default=None),
status: Optional[str] = Query(default=None),
skill_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
):
"""
List exercises with optional filters.
Filters:
- focus_area: karate, selbstverteidigung, gewaltschutz
- visibility: private, club, official
- status: draft, in_review, approved, archived
- skill_id: Filter by associated skill
"""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Base query
query = """
SELECT DISTINCT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
"""
# Add skill join if filtering by skill
if skill_id:
query += " LEFT JOIN exercise_skills es ON e.id = es.exercise_id"
# Build WHERE clause
where = []
params = []
# Visibility filter: show own private + club + official
where.append("(e.visibility = 'official' OR e.visibility = 'club' OR e.created_by = %s)")
params.append(profile_id)
if focus_area:
where.append("e.focus_area = %s")
params.append(focus_area)
if visibility:
where.append("e.visibility = %s")
params.append(visibility)
if status:
where.append("e.status = %s")
params.append(status)
if skill_id:
where.append("es.skill_id = %s")
params.append(skill_id)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY e.created_at DESC"
cur.execute(query, params)
rows = cur.fetchall()
return [r2d(r) for r in rows]
# ── Get Exercise ──────────────────────────────────────────────────────────
@router.get("/exercises/{exercise_id}")
def get_exercise(exercise_id: int, session=Depends(require_auth)):
"""Get exercise by ID with associated skills and variants."""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Get exercise
cur.execute("""
SELECT e.*,
p.name as creator_name,
c.name as club_name,
tm.name as primary_method_name
FROM exercises e
LEFT JOIN profiles p ON e.created_by = p.id
LEFT JOIN clubs c ON e.club_id = c.id
LEFT JOIN training_methods tm ON e.primary_method_id = tm.id
WHERE e.id = %s
""", (exercise_id,))
exercise = cur.fetchone()
if not exercise:
raise HTTPException(404, "Übung nicht gefunden")
exercise = r2d(exercise)
# Check visibility
if exercise['visibility'] == 'private' and exercise['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Get associated skills
cur.execute("""
SELECT es.*, s.name as skill_name, s.category as skill_category
FROM exercise_skills es
JOIN skills s ON es.skill_id = s.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, s.name
""", (exercise_id,))
exercise['skills'] = [r2d(r) for r in cur.fetchall()]
# Get variants
cur.execute("""
SELECT * FROM exercise_variants
WHERE exercise_id = %s
ORDER BY created_at
""", (exercise_id,))
exercise['variants'] = [r2d(r) for r in cur.fetchall()]
# Get media
cur.execute("""
SELECT * FROM exercise_media
WHERE exercise_id = %s
ORDER BY sort_order, created_at
""", (exercise_id,))
exercise['media'] = [r2d(r) for r in cur.fetchall()]
# Get M:N catalog assignments
# Focus Areas
cur.execute("""
SELECT efa.*, fa.name, fa.abbreviation, fa.color
FROM exercise_focus_areas efa
JOIN focus_areas fa ON efa.focus_area_id = fa.id
WHERE efa.exercise_id = %s
ORDER BY efa.is_primary DESC, fa.name
""", (exercise_id,))
exercise['focus_areas'] = [r2d(r) for r in cur.fetchall()]
# Training Styles
cur.execute("""
SELECT es.*, ts.name, ts.abbreviation
FROM exercise_styles es
JOIN training_styles ts ON es.training_style_id = ts.id
WHERE es.exercise_id = %s
ORDER BY es.is_primary DESC, ts.name
""", (exercise_id,))
exercise['training_styles'] = [r2d(r) for r in cur.fetchall()]
# Target Groups
cur.execute("""
SELECT etg.*, tg.name, tg.description
FROM exercise_target_groups etg
JOIN target_groups tg ON etg.target_group_id = tg.id
WHERE etg.exercise_id = %s
ORDER BY etg.is_primary DESC, tg.name
""", (exercise_id,))
exercise['target_groups'] = [r2d(r) for r in cur.fetchall()]
# Age Groups
cur.execute("""
SELECT age_group FROM exercise_age_groups
WHERE exercise_id = %s
ORDER BY age_group
""", (exercise_id,))
exercise['age_groups_catalog'] = [r['age_group'] for r in cur.fetchall()]
return exercise
# ── Create Exercise ───────────────────────────────────────────────────────
@router.post("/exercises")
def create_exercise(data: dict, session=Depends(require_auth)):
"""Create new exercise."""
profile_id = session['profile_id']
# Required fields
title = data.get('title')
goal = data.get('goal')
execution = data.get('execution')
if not title or not goal or not execution:
raise HTTPException(400, "Titel, Ziel und Durchführung sind Pflichtfelder")
with get_db() as conn:
cur = get_cursor(conn)
# Insert exercise
cur.execute("""
INSERT INTO exercises (
title, summary, goal, execution, preparation, trainer_notes,
equipment, duration_min, duration_max, group_size_min, group_size_max,
age_groups, focus_area, secondary_areas, training_character,
primary_method_id, secondary_method_ids,
training_style_id, training_character_id, focus_area_id,
visibility, status, created_by, club_id
) VALUES (
%s, %s, %s, %s, %s, %s,
%s, %s, %s, %s, %s,
%s, %s, %s, %s,
%s, %s,
%s, %s, %s,
%s, %s, %s, %s
) RETURNING id
""", (
title,
data.get('summary'),
goal,
execution,
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'), # JSONB
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'), # JSONB
data.get('focus_area'), # Legacy
data.get('secondary_areas'), # JSONB
data.get('training_character'), # Legacy
data.get('primary_method_id'),
data.get('secondary_method_ids'), # JSONB
data.get('training_style_id'), # NEU
data.get('training_character_id'), # NEU
data.get('focus_area_id'), # NEU
data.get('visibility', 'private'),
data.get('status', 'draft'),
profile_id,
data.get('club_id')
))
exercise_id = cur.fetchone()['id']
# Add skills if provided
if data.get('skills'):
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
# Add M:N catalog assignments if provided
# Focus Areas
if data.get('focus_areas_multi'):
for fa in data['focus_areas_multi']:
cur.execute("""
INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, fa['focus_area_id'], fa.get('is_primary', False)))
# Training Styles
if data.get('training_styles_multi'):
for ts in data['training_styles_multi']:
cur.execute("""
INSERT INTO exercise_styles (exercise_id, training_style_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, ts['training_style_id'], ts.get('is_primary', False)))
# Target Groups
if data.get('target_groups_multi'):
for tg in data['target_groups_multi']:
cur.execute("""
INSERT INTO exercise_target_groups (exercise_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, tg['target_group_id'], tg.get('is_primary', False)))
# Age Groups
if data.get('age_groups_catalog'):
for age_group in data['age_groups_catalog']:
cur.execute("""
INSERT INTO exercise_age_groups (exercise_id, age_group)
VALUES (%s, %s)
""", (exercise_id, age_group))
conn.commit()
return get_exercise(exercise_id, session)
# ── Update Exercise ───────────────────────────────────────────────────────
@router.put("/exercises/{exercise_id}")
def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)):
"""Update exercise."""
profile_id = session['profile_id']
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
if row['created_by'] != profile_id:
raise HTTPException(403, "Keine Berechtigung")
# Update exercise
cur.execute("""
UPDATE exercises SET
title = %s, summary = %s, goal = %s, execution = %s,
preparation = %s, trainer_notes = %s, equipment = %s,
duration_min = %s, duration_max = %s,
group_size_min = %s, group_size_max = %s,
age_groups = %s, focus_area = %s, secondary_areas = %s,
training_character = %s, primary_method_id = %s,
secondary_method_ids = %s,
training_style_id = %s, training_character_id = %s, focus_area_id = %s,
visibility = %s, status = %s,
club_id = %s, updated_at = NOW()
WHERE id = %s
""", (
data.get('title'),
data.get('summary'),
data.get('goal'),
data.get('execution'),
data.get('preparation'),
data.get('trainer_notes'),
data.get('equipment'),
data.get('duration_min'),
data.get('duration_max'),
data.get('group_size_min'),
data.get('group_size_max'),
data.get('age_groups'),
data.get('focus_area'), # Legacy
data.get('secondary_areas'),
data.get('training_character'), # Legacy
data.get('primary_method_id'),
data.get('secondary_method_ids'),
data.get('training_style_id'), # NEU
data.get('training_character_id'), # NEU
data.get('focus_area_id'), # NEU
data.get('visibility'),
data.get('status'),
data.get('club_id'),
exercise_id
))
# Update skills if provided
if 'skills' in data:
# Delete existing skills
cur.execute("DELETE FROM exercise_skills WHERE exercise_id = %s", (exercise_id,))
# Add new skills
for skill in data['skills']:
cur.execute("""
INSERT INTO exercise_skills (
exercise_id, skill_id, is_primary, intensity,
development_contribution, required_level, target_level
) VALUES (%s, %s, %s, %s, %s, %s, %s)
""", (
exercise_id,
skill['skill_id'],
skill.get('is_primary', False),
skill.get('intensity'),
skill.get('development_contribution'),
skill.get('required_level'),
skill.get('target_level')
))
# Update M:N catalog assignments if provided
# Focus Areas
if 'focus_areas_multi' in data:
cur.execute("DELETE FROM exercise_focus_areas WHERE exercise_id = %s", (exercise_id,))
for fa in data['focus_areas_multi']:
cur.execute("""
INSERT INTO exercise_focus_areas (exercise_id, focus_area_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, fa['focus_area_id'], fa.get('is_primary', False)))
# Training Styles
if 'training_styles_multi' in data:
cur.execute("DELETE FROM exercise_styles WHERE exercise_id = %s", (exercise_id,))
for ts in data['training_styles_multi']:
cur.execute("""
INSERT INTO exercise_styles (exercise_id, training_style_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, ts['training_style_id'], ts.get('is_primary', False)))
# Target Groups
if 'target_groups_multi' in data:
cur.execute("DELETE FROM exercise_target_groups WHERE exercise_id = %s", (exercise_id,))
for tg in data['target_groups_multi']:
cur.execute("""
INSERT INTO exercise_target_groups (exercise_id, target_group_id, is_primary)
VALUES (%s, %s, %s)
""", (exercise_id, tg['target_group_id'], tg.get('is_primary', False)))
# Age Groups
if 'age_groups_catalog' in data:
cur.execute("DELETE FROM exercise_age_groups WHERE exercise_id = %s", (exercise_id,))
for age_group in data['age_groups_catalog']:
cur.execute("""
INSERT INTO exercise_age_groups (exercise_id, age_group)
VALUES (%s, %s)
""", (exercise_id, age_group))
conn.commit()
return get_exercise(exercise_id, session)
# ── Delete Exercise ───────────────────────────────────────────────────────
@router.delete("/exercises/{exercise_id}")
def delete_exercise(exercise_id: int, session=Depends(require_auth)):
"""Delete exercise (only owner or admin)."""
profile_id = session['profile_id']
role = session.get('role')
with get_db() as conn:
cur = get_cursor(conn)
# Check ownership or admin
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
row = cur.fetchone()
if not row:
raise HTTPException(404, "Übung nicht gefunden")
if row['created_by'] != profile_id and role not in ['admin', 'superadmin']:
raise HTTPException(403, "Keine Berechtigung")
# Delete (CASCADE handles skills, variants, media)
cur.execute("DELETE FROM exercises WHERE id = %s", (exercise_id,))
conn.commit()
return {"ok": True}