diff --git a/backend/main.py b/backend/main.py index 4e09170..06a2849 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,13 +70,14 @@ def read_root(): } # Register routers -from routers import auth, profiles +from routers import auth, profiles, exercises app.include_router(auth.router) app.include_router(profiles.router) +app.include_router(exercises.router) # TODO: Add more routers as they are created -# from routers import clubs, groups, skills, methods, exercises +# from routers import clubs, groups, skills, methods # app.include_router(clubs.router, prefix="/api") # ... etc diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py new file mode 100644 index 0000000..6d1a5aa --- /dev/null +++ b/backend/routers/exercises.py @@ -0,0 +1,334 @@ +""" +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()] + + 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, + 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 + ) 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'), + data.get('secondary_areas'), # JSONB + data.get('training_character'), + data.get('primary_method_id'), + data.get('secondary_method_ids'), # JSONB + 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') + )) + + 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, 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'), + data.get('secondary_areas'), + data.get('training_character'), + data.get('primary_method_id'), + data.get('secondary_method_ids'), + 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') + )) + + 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} diff --git a/frontend/src/pages/ExercisesPage.jsx b/frontend/src/pages/ExercisesPage.jsx index 1bd768b..ba2572b 100644 --- a/frontend/src/pages/ExercisesPage.jsx +++ b/frontend/src/pages/ExercisesPage.jsx @@ -1,14 +1,566 @@ +import React, { useState, useEffect } from 'react' +import api from '../utils/api' + function ExercisesPage() { + const [exercises, setExercises] = useState([]) + const [skills, setSkills] = useState([]) + const [loading, setLoading] = useState(true) + const [showModal, setShowModal] = useState(false) + const [editingExercise, setEditingExercise] = useState(null) + const [filters, setFilters] = useState({ + focus_area: '', + visibility: '', + status: '' + }) + + // Form state + const [formData, setFormData] = useState({ + 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: '', + visibility: 'private', + status: 'draft', + skills: [] + }) + + useEffect(() => { + loadData() + }, [filters]) + + const loadData = async () => { + try { + const [exercisesData, skillsData] = await Promise.all([ + api.listExercises(filters), + api.listSkills() + ]) + setExercises(exercisesData) + setSkills(skillsData) + } catch (err) { + console.error('Failed to load data:', err) + alert('Fehler beim Laden: ' + err.message) + } finally { + setLoading(false) + } + } + + const handleCreate = () => { + setEditingExercise(null) + setFormData({ + 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: '', + visibility: 'private', + status: 'draft', + skills: [] + }) + setShowModal(true) + } + + const handleEdit = (exercise) => { + setEditingExercise(exercise) + setFormData({ + title: exercise.title || '', + summary: exercise.summary || '', + goal: exercise.goal || '', + execution: exercise.execution || '', + preparation: exercise.preparation || '', + trainer_notes: exercise.trainer_notes || '', + equipment: exercise.equipment || [], + duration_min: exercise.duration_min || '', + duration_max: exercise.duration_max || '', + group_size_min: exercise.group_size_min || '', + group_size_max: exercise.group_size_max || '', + age_groups: exercise.age_groups || [], + focus_area: exercise.focus_area || '', + secondary_areas: exercise.secondary_areas || [], + training_character: exercise.training_character || '', + visibility: exercise.visibility || 'private', + status: exercise.status || 'draft', + skills: exercise.skills?.map(s => ({ + skill_id: s.skill_id, + is_primary: s.is_primary || false, + intensity: s.intensity || null, + development_contribution: s.development_contribution || null, + required_level: s.required_level || null, + target_level: s.target_level || null + })) || [] + }) + setShowModal(true) + } + + const handleDelete = async (exercise) => { + if (!confirm(`Übung "${exercise.title}" wirklich löschen?`)) return + + try { + await api.deleteExercise(exercise.id) + await loadData() + } catch (err) { + alert('Fehler beim Löschen: ' + err.message) + } + } + + const handleSubmit = async (e) => { + e.preventDefault() + + if (!formData.title || !formData.goal || !formData.execution) { + alert('Titel, Ziel und Durchführung sind Pflichtfelder') + return + } + + try { + if (editingExercise) { + await api.updateExercise(editingExercise.id, formData) + } else { + await api.createExercise(formData) + } + setShowModal(false) + await loadData() + } catch (err) { + alert('Fehler beim Speichern: ' + err.message) + } + } + + const updateFormField = (field, value) => { + setFormData(prev => ({ ...prev, [field]: value })) + } + + const toggleSkill = (skillId) => { + const existing = formData.skills.find(s => s.skill_id === skillId) + if (existing) { + updateFormField('skills', formData.skills.filter(s => s.skill_id !== skillId)) + } else { + updateFormField('skills', [...formData.skills, { + skill_id: skillId, + is_primary: false, + intensity: null, + development_contribution: null, + required_level: null, + target_level: null + }]) + } + } + + if (loading) { + return ( +
+
+

Laden...

+
+ ) + } + return (
-

Übungsverwaltung

- -
-

- Übungsverwaltung wird als nächstes implementiert -

+ {/* Header */} +
+

Übungen

+
+ + {/* Filters */} +
+
+
+ + +
+ +
+ + +
+ +
+ + +
+
+
+ + {/* Exercises Grid */} + {exercises.length === 0 ? ( +
+

+ Keine Übungen gefunden. Lege jetzt deine erste Übung an! +

+
+ ) : ( +
+ {exercises.map(exercise => ( +
+
+

{exercise.title}

+
+ + {exercise.focus_area || 'Ohne Fokus'} + + + {exercise.visibility} + + + {exercise.status} + +
+
+ + {exercise.summary && ( +

+ {exercise.summary} +

+ )} + +
+ + +
+
+ ))} +
+ )} + + {/* Create/Edit Modal */} + {showModal && ( +
+
+

+ {editingExercise ? 'Übung bearbeiten' : 'Neue Übung'} +

+ +
+
+ + updateFormField('title', e.target.value)} + required + /> +
+ +
+ +