feat: Exercise Catalogs - Admin-verwaltbare Stammdaten (Backend)
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)
This commit is contained in:
parent
32f716304e
commit
43c6abce4a
|
|
@ -70,7 +70,7 @@ def read_root():
|
||||||
}
|
}
|
||||||
|
|
||||||
# Register routers
|
# 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(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -78,6 +78,7 @@ app.include_router(exercises.router)
|
||||||
app.include_router(clubs.router)
|
app.include_router(clubs.router)
|
||||||
app.include_router(skills.router)
|
app.include_router(skills.router)
|
||||||
app.include_router(training_planning.router)
|
app.include_router(training_planning.router)
|
||||||
|
app.include_router(catalogs.router)
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
import uvicorn
|
import uvicorn
|
||||||
|
|
|
||||||
129
backend/migrations/007_exercise_catalogs.sql
Normal file
129
backend/migrations/007_exercise_catalogs.sql
Normal file
|
|
@ -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);
|
||||||
578
backend/routers/catalogs.py
Normal file
578
backend/routers/catalogs.py
Normal file
|
|
@ -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}
|
||||||
|
|
@ -170,12 +170,14 @@ def create_exercise(data: dict, session=Depends(require_auth)):
|
||||||
equipment, duration_min, duration_max, group_size_min, group_size_max,
|
equipment, duration_min, duration_max, group_size_min, group_size_max,
|
||||||
age_groups, focus_area, secondary_areas, training_character,
|
age_groups, focus_area, secondary_areas, training_character,
|
||||||
primary_method_id, secondary_method_ids,
|
primary_method_id, secondary_method_ids,
|
||||||
|
training_style_id, training_character_id, focus_area_id,
|
||||||
visibility, status, created_by, club_id
|
visibility, status, created_by, club_id
|
||||||
) VALUES (
|
) 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, %s, %s,
|
%s, %s, %s, %s,
|
||||||
%s, %s,
|
%s, %s,
|
||||||
|
%s, %s, %s,
|
||||||
%s, %s, %s, %s
|
%s, %s, %s, %s
|
||||||
) RETURNING id
|
) RETURNING id
|
||||||
""", (
|
""", (
|
||||||
|
|
@ -191,11 +193,14 @@ def create_exercise(data: dict, session=Depends(require_auth)):
|
||||||
data.get('group_size_min'),
|
data.get('group_size_min'),
|
||||||
data.get('group_size_max'),
|
data.get('group_size_max'),
|
||||||
data.get('age_groups'), # JSONB
|
data.get('age_groups'), # JSONB
|
||||||
data.get('focus_area'),
|
data.get('focus_area'), # Legacy
|
||||||
data.get('secondary_areas'), # JSONB
|
data.get('secondary_areas'), # JSONB
|
||||||
data.get('training_character'),
|
data.get('training_character'), # Legacy
|
||||||
data.get('primary_method_id'),
|
data.get('primary_method_id'),
|
||||||
data.get('secondary_method_ids'), # JSONB
|
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('visibility', 'private'),
|
||||||
data.get('status', 'draft'),
|
data.get('status', 'draft'),
|
||||||
profile_id,
|
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,
|
group_size_min = %s, group_size_max = %s,
|
||||||
age_groups = %s, focus_area = %s, secondary_areas = %s,
|
age_groups = %s, focus_area = %s, secondary_areas = %s,
|
||||||
training_character = %s, primary_method_id = %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()
|
club_id = %s, updated_at = NOW()
|
||||||
WHERE id = %s
|
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_min'),
|
||||||
data.get('group_size_max'),
|
data.get('group_size_max'),
|
||||||
data.get('age_groups'),
|
data.get('age_groups'),
|
||||||
data.get('focus_area'),
|
data.get('focus_area'), # Legacy
|
||||||
data.get('secondary_areas'),
|
data.get('secondary_areas'),
|
||||||
data.get('training_character'),
|
data.get('training_character'), # Legacy
|
||||||
data.get('primary_method_id'),
|
data.get('primary_method_id'),
|
||||||
data.get('secondary_method_ids'),
|
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('visibility'),
|
||||||
data.get('status'),
|
data.get('status'),
|
||||||
data.get('club_id'),
|
data.get('club_id'),
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user