From 43c6abce4a24a032edcb2bbf849913f25d05243f Mon Sep 17 00:00:00 2001 From: Lars Date: Wed, 22 Apr 2026 22:06:11 +0200 Subject: [PATCH] feat: Exercise Catalogs - Admin-verwaltbare Stammdaten (Backend) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Problem: Hard-codierte Werte (Fokusbereich, Trainingscharakter) + fehlende Dimensionen (Stil, Fähigkeiten-Matrix) + keine Rollen-basierte Sichtbarkeit Lösung: Dynamische Kataloge mit Admin-CRUD Migration 007_exercise_catalogs.sql: - focus_areas (statt hard-coded 'karate', 'selbstverteidigung', 'gewaltschutz') - training_styles (NEU: Shotokan, Goju-Ryu, Wado-Ryu, etc. mit Hierarchie) - training_characters (statt hard-coded 'grundlage', 'aufbau', etc.) - skill_categories (Matrix: Kategorien → Einzelfähigkeiten) - trainer_focus_areas (Zuordnung: Trainer → Fokusbereiche) - exercises erweitert: training_style_id, training_character_id, focus_area_id - skills erweitert: category_id, parent_skill_id, level, sort_order - Seed-Daten für alle Kataloge Backend (routers/catalogs.py): - CRUD für focus_areas (admin only) - CRUD für training_styles (admin only, mit parent_style_id) - CRUD für training_characters (admin only) - CRUD für skill_categories (admin only, mit parent_category_id) - CRUD für trainer_focus_areas (admin: assign, trainer: read own) - Alle mit status-Filter (active/inactive) Backend (routers/exercises.py): - CREATE/UPDATE erweitert um training_style_id, training_character_id, focus_area_id - Legacy-Felder (focus_area text, training_character text) bleiben parallel Backend (main.py): - catalogs Router registriert Nächster Schritt: Frontend-UI (Admin-Kataloge + Exercise-Formular-Update) --- backend/main.py | 3 +- backend/migrations/007_exercise_catalogs.sql | 129 +++++ backend/routers/catalogs.py | 578 +++++++++++++++++++ backend/routers/exercises.py | 20 +- 4 files changed, 724 insertions(+), 6 deletions(-) create mode 100644 backend/migrations/007_exercise_catalogs.sql create mode 100644 backend/routers/catalogs.py diff --git a/backend/main.py b/backend/main.py index 4a883fc..f7b6902 100644 --- a/backend/main.py +++ b/backend/main.py @@ -70,7 +70,7 @@ def read_root(): } # Register routers -from routers import auth, profiles, exercises, clubs, skills, training_planning +from routers import auth, profiles, exercises, clubs, skills, training_planning, catalogs app.include_router(auth.router) app.include_router(profiles.router) @@ -78,6 +78,7 @@ app.include_router(exercises.router) app.include_router(clubs.router) app.include_router(skills.router) app.include_router(training_planning.router) +app.include_router(catalogs.router) if __name__ == "__main__": import uvicorn diff --git a/backend/migrations/007_exercise_catalogs.sql b/backend/migrations/007_exercise_catalogs.sql new file mode 100644 index 0000000..c99ea2b --- /dev/null +++ b/backend/migrations/007_exercise_catalogs.sql @@ -0,0 +1,129 @@ +-- Migration 007: Exercise Catalogs (Admin-verwaltbare Stammdaten) +-- Erstellt: 2026-04-22 +-- Beschreibung: Dynamische Kataloge statt Hard-Coding + +-- Fokusbereiche (statt hard-coded) +CREATE TABLE focus_areas ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + abbreviation VARCHAR(20), + description TEXT, + color VARCHAR(20), -- Für UI (z.B. #1D9E75) + icon VARCHAR(50), -- Emoji oder Icon-Name + sort_order INT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_focus_areas_status ON focus_areas(status); + +-- Trainingsstile (NEU: Shotokan, Goju-Ryu, etc.) +CREATE TABLE training_styles ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + abbreviation VARCHAR(20), + description TEXT, + parent_style_id INT REFERENCES training_styles(id), -- z.B. Shotokan → Karate + sort_order INT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_training_styles_status ON training_styles(status); +CREATE INDEX idx_training_styles_parent ON training_styles(parent_style_id); + +-- Trainingscharaktere (statt hard-coded) +CREATE TABLE training_characters ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + sort_order INT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_training_characters_status ON training_characters(status); + +-- Fähigkeitsbereiche (Kategorien für Skills) +CREATE TABLE skill_categories ( + id SERIAL PRIMARY KEY, + name VARCHAR(100) NOT NULL UNIQUE, + description TEXT, + parent_category_id INT REFERENCES skill_categories(id), -- Hierarchie möglich + sort_order INT, + status VARCHAR(50) DEFAULT 'active', + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW() +); + +CREATE INDEX idx_skill_categories_status ON skill_categories(status); +CREATE INDEX idx_skill_categories_parent ON skill_categories(parent_category_id); + +-- Skills erweitern (Verknüpfung mit Kategorien) +ALTER TABLE skills +ADD COLUMN category_id INT REFERENCES skill_categories(id), +ADD COLUMN parent_skill_id INT REFERENCES skills(id), -- Hierarchie: Tsuki → Mae-Zuki +ADD COLUMN level INT CHECK (level BETWEEN 1 AND 10), -- Schwierigkeitsgrad +ADD COLUMN sort_order INT; + +CREATE INDEX idx_skills_category ON skills(category_id); +CREATE INDEX idx_skills_parent ON skills(parent_skill_id); + +-- Exercises erweitern +ALTER TABLE exercises +ADD COLUMN training_style_id INT REFERENCES training_styles(id), +ADD COLUMN training_character_id INT REFERENCES training_characters(id), +ADD COLUMN focus_area_id INT REFERENCES focus_areas(id); + +-- Alte text-basierte Spalten können später deprecated werden +-- ALTER TABLE exercises DROP COLUMN focus_area; -- später, nach Migration +-- ALTER TABLE exercises DROP COLUMN training_character; -- später, nach Migration + +CREATE INDEX idx_exercises_style ON exercises(training_style_id); +CREATE INDEX idx_exercises_character ON exercises(training_character_id); +CREATE INDEX idx_exercises_focus_area ON exercises(focus_area_id); + +-- Trainer-Fokusbereich-Zuordnung (Welcher Trainer arbeitet in welchen Fokusbereichen?) +CREATE TABLE trainer_focus_areas ( + id SERIAL PRIMARY KEY, + profile_id INT REFERENCES profiles(id) ON DELETE CASCADE, + focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, -- Primärer Fokusbereich des Trainers + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(profile_id, focus_area_id) +); + +CREATE INDEX idx_trainer_focus_areas_profile ON trainer_focus_areas(profile_id); +CREATE INDEX idx_trainer_focus_areas_focus ON trainer_focus_areas(focus_area_id); + +-- Basis-Daten einfügen +INSERT INTO focus_areas (name, abbreviation, description, color, icon, sort_order) VALUES +('Karate', 'KAR', 'Traditionelles Karate', '#1D9E75', '🥋', 1), +('Selbstverteidigung', 'SV', 'Praktische Selbstverteidigung', '#D85A30', '🛡️', 2), +('Gewaltschutz', 'GS', 'Gewaltprävention und Deeskalation', '#EF9F27', '🤝', 3); + +INSERT INTO training_styles (name, abbreviation, description, sort_order) VALUES +('Shotokan', 'SHO', 'Shotokan-Karate', 1), +('Goju-Ryu', 'GJR', 'Goju-Ryu-Karate', 2), +('Wado-Ryu', 'WAD', 'Wado-Ryu-Karate', 3), +('Shito-Ryu', 'SHI', 'Shito-Ryu-Karate', 4), +('Kyokushin', 'KYO', 'Kyokushin-Karate', 5); + +INSERT INTO training_characters (name, description, sort_order) VALUES +('Grundlage', 'Einführung und Basisvermittlung', 1), +('Aufbau', 'Aufbauendes Training', 2), +('Vertiefung', 'Vertiefung und Spezialisierung', 3), +('Festigung', 'Wiederholung und Festigung', 4), +('Diagnose', 'Leistungsdiagnose und Test', 5), +('Wettkampf', 'Wettkampfvorbereitung', 6); + +INSERT INTO skill_categories (name, description, sort_order) VALUES +('Kihon', 'Grundschultechniken', 1), +('Kumite', 'Kampftechniken', 2), +('Kata', 'Formen', 3), +('Selbstverteidigung', 'SV-Techniken', 4), +('Fitness', 'Kondition und Athletik', 5), +('Mental', 'Mentale Fähigkeiten', 6); diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py new file mode 100644 index 0000000..52927f1 --- /dev/null +++ b/backend/routers/catalogs.py @@ -0,0 +1,578 @@ +""" +Catalog Management Endpoints for Shinkan Jinkendo + +Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc. +""" +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=["catalogs"]) + + +# ════════════════════════════════════════════════════════════════════════ +# FOCUS AREAS +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/focus-areas") +def list_focus_areas( + status: Optional[str] = Query(default='active'), + session=Depends(require_auth) +): + """List all focus areas (public for authenticated users).""" + with get_db() as conn: + cur = get_cursor(conn) + + query = "SELECT * FROM focus_areas" + params = [] + + if status: + query += " WHERE status = %s" + params.append(status) + + query += " ORDER BY sort_order, name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/focus-areas") +def create_focus_area(data: dict, session=Depends(require_auth)): + """Create new focus area (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Fokusbereiche erstellen") + + name = data.get('name') + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO focus_areas (name, abbreviation, description, color, icon, sort_order, status) + VALUES (%s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + name, + data.get('abbreviation'), + data.get('description'), + data.get('color'), + data.get('icon'), + data.get('sort_order', 99), + data.get('status', 'active') + )) + + focus_area_id = cur.fetchone()['id'] + conn.commit() + + cur.execute("SELECT * FROM focus_areas WHERE id = %s", (focus_area_id,)) + return r2d(cur.fetchone()) + + +@router.put("/focus-areas/{focus_area_id}") +def update_focus_area(focus_area_id: int, data: dict, session=Depends(require_auth)): + """Update focus area (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Fokusbereiche bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE focus_areas SET + name = %s, + abbreviation = %s, + description = %s, + color = %s, + icon = %s, + sort_order = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('abbreviation'), + data.get('description'), + data.get('color'), + data.get('icon'), + data.get('sort_order'), + data.get('status'), + focus_area_id + )) + + conn.commit() + + cur.execute("SELECT * FROM focus_areas WHERE id = %s", (focus_area_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/focus-areas/{focus_area_id}") +def delete_focus_area(focus_area_id: int, session=Depends(require_auth)): + """Delete focus area (superadmin only).""" + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Fokusbereiche löschen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM focus_areas WHERE id = %s", (focus_area_id,)) + conn.commit() + + return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# TRAINING STYLES +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/training-styles") +def list_training_styles( + status: Optional[str] = Query(default='active'), + session=Depends(require_auth) +): + """List all training styles.""" + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT ts.*, ps.name as parent_style_name + FROM training_styles ts + LEFT JOIN training_styles ps ON ts.parent_style_id = ps.id + """ + params = [] + + if status: + query += " WHERE ts.status = %s" + params.append(status) + + query += " ORDER BY ts.sort_order, ts.name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/training-styles") +def create_training_style(data: dict, session=Depends(require_auth)): + """Create new training style (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Trainingsstile erstellen") + + name = data.get('name') + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO training_styles (name, abbreviation, description, parent_style_id, sort_order, status) + VALUES (%s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + name, + data.get('abbreviation'), + data.get('description'), + data.get('parent_style_id'), + data.get('sort_order', 99), + data.get('status', 'active') + )) + + style_id = cur.fetchone()['id'] + conn.commit() + + cur.execute(""" + SELECT ts.*, ps.name as parent_style_name + FROM training_styles ts + LEFT JOIN training_styles ps ON ts.parent_style_id = ps.id + WHERE ts.id = %s + """, (style_id,)) + return r2d(cur.fetchone()) + + +@router.put("/training-styles/{style_id}") +def update_training_style(style_id: int, data: dict, session=Depends(require_auth)): + """Update training style (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Trainingsstile bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE training_styles SET + name = %s, + abbreviation = %s, + description = %s, + parent_style_id = %s, + sort_order = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('abbreviation'), + data.get('description'), + data.get('parent_style_id'), + data.get('sort_order'), + data.get('status'), + style_id + )) + + conn.commit() + + cur.execute(""" + SELECT ts.*, ps.name as parent_style_name + FROM training_styles ts + LEFT JOIN training_styles ps ON ts.parent_style_id = ps.id + WHERE ts.id = %s + """, (style_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/training-styles/{style_id}") +def delete_training_style(style_id: int, session=Depends(require_auth)): + """Delete training style (superadmin only).""" + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Trainingsstile löschen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM training_styles WHERE id = %s", (style_id,)) + conn.commit() + + return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# TRAINING CHARACTERS +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/training-characters") +def list_training_characters( + status: Optional[str] = Query(default='active'), + session=Depends(require_auth) +): + """List all training characters.""" + with get_db() as conn: + cur = get_cursor(conn) + + query = "SELECT * FROM training_characters" + params = [] + + if status: + query += " WHERE status = %s" + params.append(status) + + query += " ORDER BY sort_order, name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/training-characters") +def create_training_character(data: dict, session=Depends(require_auth)): + """Create new training character (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Trainingscharaktere erstellen") + + name = data.get('name') + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO training_characters (name, description, sort_order, status) + VALUES (%s, %s, %s, %s) + RETURNING id + """, ( + name, + data.get('description'), + data.get('sort_order', 99), + data.get('status', 'active') + )) + + char_id = cur.fetchone()['id'] + conn.commit() + + cur.execute("SELECT * FROM training_characters WHERE id = %s", (char_id,)) + return r2d(cur.fetchone()) + + +@router.put("/training-characters/{char_id}") +def update_training_character(char_id: int, data: dict, session=Depends(require_auth)): + """Update training character (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Trainingscharaktere bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE training_characters SET + name = %s, + description = %s, + sort_order = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('description'), + data.get('sort_order'), + data.get('status'), + char_id + )) + + conn.commit() + + cur.execute("SELECT * FROM training_characters WHERE id = %s", (char_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/training-characters/{char_id}") +def delete_training_character(char_id: int, session=Depends(require_auth)): + """Delete training character (superadmin only).""" + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Trainingscharaktere löschen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM training_characters WHERE id = %s", (char_id,)) + conn.commit() + + return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# SKILL CATEGORIES +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/skill-categories") +def list_skill_categories( + status: Optional[str] = Query(default='active'), + session=Depends(require_auth) +): + """List all skill categories.""" + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT sc.*, pc.name as parent_category_name + FROM skill_categories sc + LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + """ + params = [] + + if status: + query += " WHERE sc.status = %s" + params.append(status) + + query += " ORDER BY sc.sort_order, sc.name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/skill-categories") +def create_skill_category(data: dict, session=Depends(require_auth)): + """Create new skill category (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen") + + name = data.get('name') + if not name: + raise HTTPException(400, "Name ist Pflichtfeld") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO skill_categories (name, description, parent_category_id, sort_order, status) + VALUES (%s, %s, %s, %s, %s) + RETURNING id + """, ( + name, + data.get('description'), + data.get('parent_category_id'), + data.get('sort_order', 99), + data.get('status', 'active') + )) + + cat_id = cur.fetchone()['id'] + conn.commit() + + cur.execute(""" + SELECT sc.*, pc.name as parent_category_name + FROM skill_categories sc + LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + WHERE sc.id = %s + """, (cat_id,)) + return r2d(cur.fetchone()) + + +@router.put("/skill-categories/{cat_id}") +def update_skill_category(cat_id: int, data: dict, session=Depends(require_auth)): + """Update skill category (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE skill_categories SET + name = %s, + description = %s, + parent_category_id = %s, + sort_order = %s, + status = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('description'), + data.get('parent_category_id'), + data.get('sort_order'), + data.get('status'), + cat_id + )) + + conn.commit() + + cur.execute(""" + SELECT sc.*, pc.name as parent_category_name + FROM skill_categories sc + LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id + WHERE sc.id = %s + """, (cat_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/skill-categories/{cat_id}") +def delete_skill_category(cat_id: int, session=Depends(require_auth)): + """Delete skill category (superadmin only).""" + role = session.get('role') + if role != 'superadmin': + raise HTTPException(403, "Nur Superadmins dürfen Fähigkeitsbereiche löschen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM skill_categories WHERE id = %s", (cat_id,)) + conn.commit() + + return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# TRAINER FOCUS AREAS (Welcher Trainer arbeitet in welchen Fokusbereichen?) +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/trainer-focus-areas") +def list_trainer_focus_areas( + profile_id: Optional[int] = Query(default=None), + session=Depends(require_auth) +): + """List trainer focus area assignments.""" + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT tfa.*, fa.name as focus_area_name, fa.abbreviation as focus_area_abbr, + p.name as trainer_name + FROM trainer_focus_areas tfa + LEFT JOIN focus_areas fa ON tfa.focus_area_id = fa.id + LEFT JOIN profiles p ON tfa.profile_id = p.id + """ + params = [] + + # If not admin, only show own focus areas + role = session.get('role') + current_profile_id = session['profile_id'] + + if role not in ['admin', 'superadmin']: + query += " WHERE tfa.profile_id = %s" + params.append(current_profile_id) + elif profile_id: + query += " WHERE tfa.profile_id = %s" + params.append(profile_id) + + query += " ORDER BY fa.name" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/trainer-focus-areas") +def assign_trainer_focus_area(data: dict, session=Depends(require_auth)): + """Assign focus area to trainer (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Fokusbereiche zuweisen") + + profile_id = data.get('profile_id') + focus_area_id = data.get('focus_area_id') + + if not profile_id or not focus_area_id: + raise HTTPException(400, "profile_id und focus_area_id sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + INSERT INTO trainer_focus_areas (profile_id, focus_area_id, is_primary) + VALUES (%s, %s, %s) + ON CONFLICT (profile_id, focus_area_id) DO UPDATE + SET is_primary = EXCLUDED.is_primary + RETURNING id + """, ( + profile_id, + focus_area_id, + data.get('is_primary', False) + )) + + tfa_id = cur.fetchone()['id'] + conn.commit() + + cur.execute(""" + SELECT tfa.*, fa.name as focus_area_name, p.name as trainer_name + FROM trainer_focus_areas tfa + LEFT JOIN focus_areas fa ON tfa.focus_area_id = fa.id + LEFT JOIN profiles p ON tfa.profile_id = p.id + WHERE tfa.id = %s + """, (tfa_id,)) + return r2d(cur.fetchone()) + + +@router.delete("/trainer-focus-areas/{tfa_id}") +def delete_trainer_focus_area(tfa_id: int, session=Depends(require_auth)): + """Remove focus area assignment (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zuweisungen entfernen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM trainer_focus_areas WHERE id = %s", (tfa_id,)) + conn.commit() + + return {"ok": True} diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 6d1a5aa..706c4d9 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -170,12 +170,14 @@ def create_exercise(data: dict, session=Depends(require_auth)): 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 """, ( @@ -191,11 +193,14 @@ def create_exercise(data: dict, session=Depends(require_auth)): data.get('group_size_min'), data.get('group_size_max'), data.get('age_groups'), # JSONB - data.get('focus_area'), + data.get('focus_area'), # Legacy data.get('secondary_areas'), # JSONB - data.get('training_character'), + 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, @@ -254,7 +259,9 @@ def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)) 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, + 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 """, ( @@ -270,11 +277,14 @@ def update_exercise(exercise_id: int, data: dict, session=Depends(require_auth)) data.get('group_size_min'), data.get('group_size_max'), data.get('age_groups'), - data.get('focus_area'), + data.get('focus_area'), # Legacy data.get('secondary_areas'), - data.get('training_character'), + 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'),