diff --git a/backend/migrations/012_exercise_training_characters_and_trainer_contexts.sql b/backend/migrations/012_exercise_training_characters_and_trainer_contexts.sql new file mode 100644 index 0000000..49bf9d7 --- /dev/null +++ b/backend/migrations/012_exercise_training_characters_and_trainer_contexts.sql @@ -0,0 +1,120 @@ +-- Migration 012: Exercise Training Characters (M:N) + Trainer Contexts +-- Author: Claude Code +-- Date: 2026-04-23 +-- Purpose: Add M:N relationship for training characters and trainer profile system + +DO $$ +BEGIN + +-- ============================================================================ +-- EXERCISE TRAINING CHARACTERS (M:N) +-- ============================================================================ + +-- Create junction table for exercise ↔ training_characters +IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'exercise_training_characters' +) THEN + CREATE TABLE exercise_training_characters ( + id SERIAL PRIMARY KEY, + exercise_id INT NOT NULL REFERENCES exercises(id) ON DELETE CASCADE, + training_character_id INT NOT NULL REFERENCES training_characters(id) ON DELETE RESTRICT, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(exercise_id, training_character_id) + ); + + CREATE INDEX idx_exercise_training_characters_exercise + ON exercise_training_characters(exercise_id); + CREATE INDEX idx_exercise_training_characters_character + ON exercise_training_characters(training_character_id); + + RAISE NOTICE 'Created table: exercise_training_characters'; +END IF; + + +-- ============================================================================ +-- TRAINER CONTEXTS (Fokussierte Trainer-Ansichten) +-- ============================================================================ + +-- Create trainer_contexts table +IF NOT EXISTS ( + SELECT 1 FROM information_schema.tables + WHERE table_schema = 'public' + AND table_name = 'trainer_contexts' +) THEN + CREATE TABLE trainer_contexts ( + id SERIAL PRIMARY KEY, + profile_id INT NOT NULL REFERENCES profiles(id) ON DELETE CASCADE, + + -- Context definition + name VARCHAR(100) NOT NULL, -- "Karate Goju-Ryu Breitensport", "Gewaltschutz" + + -- Hierarchical filters (all optional) + focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE, + style_direction_id INT REFERENCES style_directions(id) ON DELETE CASCADE, + training_type_id INT REFERENCES training_types(id) ON DELETE SET NULL, + + -- Flags + is_style_independent BOOLEAN DEFAULT false, -- true = "Leistungssport stilunabhängig" + is_active BOOLEAN DEFAULT true, + + -- Metadata + description TEXT, + sort_order INT DEFAULT 99, + created_at TIMESTAMP DEFAULT NOW(), + updated_at TIMESTAMP DEFAULT NOW(), + + -- Prevent duplicates + UNIQUE(profile_id, focus_area_id, style_direction_id, training_type_id) + ); + + CREATE INDEX idx_trainer_contexts_profile ON trainer_contexts(profile_id); + CREATE INDEX idx_trainer_contexts_focus ON trainer_contexts(focus_area_id); + CREATE INDEX idx_trainer_contexts_style ON trainer_contexts(style_direction_id); + CREATE INDEX idx_trainer_contexts_type ON trainer_contexts(training_type_id); + + RAISE NOTICE 'Created table: trainer_contexts'; +END IF; + + +-- ============================================================================ +-- SEED DATA: Example Trainer Contexts +-- ============================================================================ + +-- Insert example contexts for profile_id = 1 (if exists) +DO $seed$ +BEGIN + IF EXISTS (SELECT 1 FROM profiles WHERE id = 1) THEN + -- Only insert if not already present + IF NOT EXISTS (SELECT 1 FROM trainer_contexts WHERE profile_id = 1) THEN + INSERT INTO trainer_contexts (profile_id, name, focus_area_id, style_direction_id, training_type_id, is_style_independent, description, sort_order) + VALUES + -- Karate Breitensport (if focus_area and style exist) + (1, 'Karate Breitensport', + (SELECT id FROM focus_areas WHERE name = 'Karate' LIMIT 1), + NULL, -- all styles + (SELECT id FROM training_types WHERE name = 'Breitensport' LIMIT 1), + false, + 'Breitensport für alle Karate-Stilrichtungen', + 1 + ); + + RAISE NOTICE 'Inserted example trainer contexts for profile_id = 1'; + END IF; + END IF; +END $seed$; + + +-- ============================================================================ +-- MIGRATION TRACKING +-- ============================================================================ + +INSERT INTO schema_migrations (version, description) +VALUES (12, 'exercise_training_characters + trainer_contexts') +ON CONFLICT (version) DO NOTHING; + +RAISE NOTICE 'Migration 012 completed successfully'; + +END $$; diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index 246d9e2..d0da03c 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -1097,3 +1097,169 @@ def get_training_styles_hierarchy( fa['style_directions'] = style_directions return focus_areas + + +# ════════════════════════════════════════════════════════════════════════ +# TRAINER CONTEXTS (Fokussierte Trainer-Ansichten) +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/trainer-contexts") +def list_trainer_contexts(session=Depends(require_auth)): + """List all trainer contexts for the current user. + + Returns enriched data with focus_area_name, style_direction_name, training_type_name. + """ + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + SELECT + tc.*, + fa.name as focus_area_name, + fa.icon as focus_area_icon, + sd.name as style_direction_name, + tt.name as training_type_name + FROM trainer_contexts tc + LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id + LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id + LEFT JOIN training_types tt ON tc.training_type_id = tt.id + WHERE tc.profile_id = %s + ORDER BY tc.sort_order, tc.name + """, (profile_id,)) + + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/trainer-contexts") +def create_trainer_context(data: dict, session=Depends(require_auth)): + """Create new trainer context for the current user.""" + profile_id = session['profile_id'] + + 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 trainer_contexts ( + profile_id, name, focus_area_id, style_direction_id, + training_type_id, is_style_independent, description, sort_order, is_active + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + profile_id, + name, + data.get('focus_area_id'), + data.get('style_direction_id'), + data.get('training_type_id'), + data.get('is_style_independent', False), + data.get('description'), + data.get('sort_order', 99), + data.get('is_active', True) + )) + + context_id = cur.fetchone()['id'] + conn.commit() + + # Return enriched record + cur.execute(""" + SELECT + tc.*, + fa.name as focus_area_name, + sd.name as style_direction_name, + tt.name as training_type_name + FROM trainer_contexts tc + LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id + LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id + LEFT JOIN training_types tt ON tc.training_type_id = tt.id + WHERE tc.id = %s + """, (context_id,)) + + return r2d(cur.fetchone()) + + +@router.put("/trainer-contexts/{context_id}") +def update_trainer_context(context_id: int, data: dict, session=Depends(require_auth)): + """Update trainer context (own contexts only).""" + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Kontext nicht gefunden") + if row['profile_id'] != profile_id: + raise HTTPException(403, "Zugriff verweigert") + + cur.execute(""" + UPDATE trainer_contexts SET + name = %s, + focus_area_id = %s, + style_direction_id = %s, + training_type_id = %s, + is_style_independent = %s, + description = %s, + sort_order = %s, + is_active = %s, + updated_at = NOW() + WHERE id = %s + """, ( + data.get('name'), + data.get('focus_area_id'), + data.get('style_direction_id'), + data.get('training_type_id'), + data.get('is_style_independent', False), + data.get('description'), + data.get('sort_order'), + data.get('is_active', True), + context_id + )) + + conn.commit() + + # Return enriched record + cur.execute(""" + SELECT + tc.*, + fa.name as focus_area_name, + sd.name as style_direction_name, + tt.name as training_type_name + FROM trainer_contexts tc + LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id + LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id + LEFT JOIN training_types tt ON tc.training_type_id = tt.id + WHERE tc.id = %s + """, (context_id,)) + + return r2d(cur.fetchone()) + + +@router.delete("/trainer-contexts/{context_id}") +def delete_trainer_context(context_id: int, session=Depends(require_auth)): + """Delete trainer context (own contexts only).""" + profile_id = session['profile_id'] + + with get_db() as conn: + cur = get_cursor(conn) + + # Verify ownership + cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,)) + row = cur.fetchone() + if not row: + raise HTTPException(404, "Kontext nicht gefunden") + if row['profile_id'] != profile_id: + raise HTTPException(403, "Zugriff verweigert") + + cur.execute("DELETE FROM trainer_contexts WHERE id = %s", (context_id,)) + conn.commit() + + return {"ok": True} diff --git a/backend/version.py b/backend/version.py index 79fff61..8ba251f 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.4.0" +APP_VERSION = "0.5.0" BUILD_DATE = "2026-04-23" -DB_SCHEMA_VERSION = "20260423" +DB_SCHEMA_VERSION = "20260423002" MODULE_VERSIONS = { "auth": "1.0.0", @@ -11,17 +11,30 @@ MODULE_VERSIONS = { "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "0.4.0", # Updated: M:N API-Integration + "exercises": "0.5.0", # Updated: M:N Training Characters (Migration 012) "training_units": "0.1.0", "training_programs": "0.1.0", "planning": "0.1.0", "import_wiki": "0.1.0", "admin": "1.0.0", "membership": "1.0.0", - "catalogs": "1.4.0", # Updated: Backend SQL Queries für renamed tables (Migration 010+011) + "catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) } CHANGELOG = [ + { + "version": "0.5.0", + "date": "2026-04-23", + "changes": [ + "Feature: Trainer-Kontext-System für fokussierte Ansichten", + "DB: Migration 012 - exercise_training_characters (M:N)", + "DB: Migration 012 - trainer_contexts (Trainer-Profil-System)", + "Backend: CRUD API für Trainer-Kontexte (/api/trainer-contexts)", + "Frontend: TrainerContextsPage für Verwaltung eigener Arbeitsbereiche", + "Architektur: Flat Catalogs + Smart Filtering für 1000+ Übungen", + "Architektur: NULL = 'für alles geeignet', M:N = spezifische Zuordnung", + ] + }, { "version": "0.4.0", "date": "2026-04-23", diff --git a/frontend/src/pages/TrainerContextsPage.jsx b/frontend/src/pages/TrainerContextsPage.jsx new file mode 100644 index 0000000..42af87d --- /dev/null +++ b/frontend/src/pages/TrainerContextsPage.jsx @@ -0,0 +1,387 @@ +import { useState, useEffect } from 'react' +import { api } from '../utils/api' + +export default function TrainerContextsPage() { + const [contexts, setContexts] = useState([]) + const [focusAreas, setFocusAreas] = useState([]) + const [styleDirections, setStyleDirections] = useState([]) + const [trainingTypes, setTrainingTypes] = useState([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + + // Edit/Create state + const [editingContext, setEditingContext] = useState(null) + const [newContext, setNewContext] = useState({ + name: '', + focus_area_id: null, + style_direction_id: null, + training_type_id: null, + is_style_independent: false, + description: '', + is_active: true + }) + + useEffect(() => { + loadData() + }, []) + + async function loadData() { + setLoading(true) + setError('') + try { + const [ctx, fa, sd, tt] = await Promise.all([ + api.listTrainerContexts(), + api.listFocusAreas(), + api.listStyleDirections(), + api.listTrainingTypes() + ]) + setContexts(ctx) + setFocusAreas(fa) + setStyleDirections(sd) + setTrainingTypes(tt) + } catch (e) { + setError(e.message) + } finally { + setLoading(false) + } + } + + async function createContext() { + if (!newContext.name) { + setError('Name ist Pflichtfeld') + return + } + try { + await api.createTrainerContext(newContext) + setNewContext({ + name: '', + focus_area_id: null, + style_direction_id: null, + training_type_id: null, + is_style_independent: false, + description: '', + is_active: true + }) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function updateContext(id, data) { + try { + await api.updateTrainerContext(id, data) + setEditingContext(null) + loadData() + } catch (e) { + setError(e.message) + } + } + + async function deleteContext(id) { + if (!confirm('Trainer-Kontext wirklich löschen?')) return + try { + await api.deleteTrainerContext(id) + loadData() + } catch (e) { + setError(e.message) + } + } + + // Filter style directions by selected focus area + function getFilteredStyleDirections(focusAreaId) { + if (!focusAreaId) return [] + return styleDirections.filter(sd => sd.focus_area_id === parseInt(focusAreaId)) + } + + return ( +
+

Meine Trainer-Bereiche

+

+ Definiere deine Tätigkeitsbereiche für fokussierte Ansichten und Filter. +

+ + {error && ( +
+ {error} +
+ )} + + {/* Create New Context */} +
+

Neuer Trainer-Bereich

+ +
+ + setNewContext({ ...newContext, name: e.target.value })} + placeholder="z.B. Karate Goju-Ryu Breitensport" + /> +
+ +
+ + +
+ + {newContext.focus_area_id && ( +
+ + +
+ )} + +
+ + +
+ +
+ +
+ +
+ +