feat: Training Types → Focus Area Hierarchie
Some checks failed
Deploy Development / deploy (push) Successful in 35s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s

Migration 013:
- Adds focus_area_id to training_types (context-specific types)
- Migrates existing data to Karate focus area
- Seeds focus-area-specific training types:
  * Karate: Dan-Vorbereitung
  * Gewaltschutz: Präventivkurse, Intensivtraining, Spezialkurse
  * Fitness: Gesundheitssport, Funktionelles Training
- Updates unique constraint to (name, focus_area_id)

Backend (catalogs.py):
- list_training_types: Added focus_area_id filter, LEFT JOIN focus_areas
- create_training_type: Added focus_area_id parameter
- update_training_type: Added focus_area_id parameter
- Enriched responses with focus_area_name and focus_area_icon

Frontend (AdminCatalogsPage):
- Added Fokusbereich dropdown to create form
- Added Fokusbereich dropdown to edit form
- Display shows focus_area_icon and focus_area_name
- Training types now context-specific to focus areas

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
This commit is contained in:
Lars 2026-04-23 15:29:23 +02:00
parent 9f44cff77b
commit fe5d29e40e
3 changed files with 212 additions and 10 deletions

View File

@ -0,0 +1,141 @@
-- Migration 013: Training Types → Focus Area Hierarchie
-- Author: Claude Code
-- Date: 2026-04-23
-- Purpose: Training Types sind kontextabhängig vom Fokusbereich
DO $$
BEGIN
-- ============================================================================
-- ADD focus_area_id TO training_types
-- ============================================================================
-- Add focus_area_id column if not exists
IF NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'training_types'
AND column_name = 'focus_area_id'
) THEN
ALTER TABLE training_types
ADD COLUMN focus_area_id INT REFERENCES focus_areas(id) ON DELETE CASCADE;
CREATE INDEX idx_training_types_focus_area ON training_types(focus_area_id);
RAISE NOTICE 'Added focus_area_id to training_types';
END IF;
-- ============================================================================
-- MIGRATE EXISTING DATA (assign to Karate if exists)
-- ============================================================================
-- Assign existing training types to Karate (if Karate focus area exists)
DO $migrate$
DECLARE
karate_id INT;
BEGIN
-- Get Karate focus area ID
SELECT id INTO karate_id FROM focus_areas WHERE name = 'Karate' LIMIT 1;
IF karate_id IS NOT NULL THEN
-- Assign existing training types to Karate
UPDATE training_types
SET focus_area_id = karate_id
WHERE focus_area_id IS NULL;
RAISE NOTICE 'Assigned existing training types to Karate (focus_area_id: %)', karate_id;
ELSE
RAISE NOTICE 'No Karate focus area found - skipping migration';
END IF;
END $migrate$;
-- ============================================================================
-- SEED EXAMPLE DATA (focus-area-specific training types)
-- ============================================================================
DO $seed$
DECLARE
karate_id INT;
gewaltschutz_id INT;
fitness_id INT;
BEGIN
-- Get focus area IDs
SELECT id INTO karate_id FROM focus_areas WHERE name = 'Karate' LIMIT 1;
SELECT id INTO gewaltschutz_id FROM focus_areas WHERE name = 'Gewaltschutz' LIMIT 1;
SELECT id INTO fitness_id FROM focus_areas WHERE name = 'Fitness' LIMIT 1;
-- Karate-specific training types (if Karate exists)
IF karate_id IS NOT NULL THEN
INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status)
VALUES
('Dan-Vorbereitung', 'DAN', 'Vorbereitung auf Dan-Prüfungen', karate_id, 4, 'active')
ON CONFLICT (name) DO NOTHING;
END IF;
-- Gewaltschutz-specific training types (if exists)
IF gewaltschutz_id IS NOT NULL THEN
INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status)
VALUES
('Präventivkurse', 'PREV', 'Präventive Selbstverteidigung und Deeskalation', gewaltschutz_id, 1, 'active'),
('Intensivtraining', 'INT', 'Intensives Selbstverteidigungs-Training', gewaltschutz_id, 2, 'active'),
('Spezialkurse', 'SPEC', 'Spezielle Kurse (Frauen, Sicherheitspersonal, etc.)', gewaltschutz_id, 3, 'active')
ON CONFLICT (name) DO NOTHING;
END IF;
-- Fitness-specific training types (if exists)
IF fitness_id IS NOT NULL THEN
INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status)
VALUES
('Gesundheitssport', 'GES', 'Gesundheitsorientiertes Training', fitness_id, 1, 'active'),
('Funktionelles Training', 'FUNKT', 'Funktionelle Fitness und Beweglichkeit', fitness_id, 2, 'active')
ON CONFLICT (name) DO NOTHING;
END IF;
RAISE NOTICE 'Seeded focus-area-specific training types';
END $seed$;
-- ============================================================================
-- UPDATE UNIQUE CONSTRAINT (if needed)
-- ============================================================================
-- Drop old unique constraint if exists
DO $constraint$
BEGIN
IF EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'training_types_name_key'
) THEN
ALTER TABLE training_types DROP CONSTRAINT training_types_name_key;
RAISE NOTICE 'Dropped old unique constraint on name';
END IF;
END $constraint$;
-- Add new unique constraint (name per focus_area)
DO $constraint$
BEGIN
IF NOT EXISTS (
SELECT 1 FROM pg_constraint
WHERE conname = 'training_types_name_focus_area_key'
) THEN
ALTER TABLE training_types
ADD CONSTRAINT training_types_name_focus_area_key
UNIQUE (name, focus_area_id);
RAISE NOTICE 'Added unique constraint: name per focus_area';
END IF;
END $constraint$;
-- ============================================================================
-- MIGRATION TRACKING
-- ============================================================================
INSERT INTO schema_migrations (version, description)
VALUES (13, 'training_types focus_area hierarchy')
ON CONFLICT (version) DO NOTHING;
RAISE NOTICE 'Migration 013 completed successfully';
END $$;

View File

@ -372,20 +372,36 @@ def delete_training_character(char_id: int, session=Depends(require_auth)):
@router.get("/training-types")
def list_training_types(
status: Optional[str] = Query(default='active'),
focus_area_id: Optional[int] = Query(default=None),
session=Depends(require_auth)
):
"""List all training types."""
"""List all training types.
Optional filter by focus_area_id for context-specific types.
"""
with get_db() as conn:
cur = get_cursor(conn)
query = "SELECT * FROM training_types"
query = """
SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon
FROM training_types tt
LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id
"""
params = []
where = []
if status:
query += " WHERE status = %s"
where.append("tt.status = %s")
params.append(status)
query += " ORDER BY sort_order, name"
if focus_area_id is not None:
where.append("tt.focus_area_id = %s")
params.append(focus_area_id)
if where:
query += " WHERE " + " AND ".join(where)
query += " ORDER BY fa.sort_order, tt.sort_order, tt.name"
cur.execute(query, params)
rows = cur.fetchall()
@ -407,13 +423,14 @@ def create_training_type(data: dict, session=Depends(require_auth)):
cur = get_cursor(conn)
cur.execute("""
INSERT INTO training_types (name, abbreviation, description, sort_order, status)
VALUES (%s, %s, %s, %s, %s)
INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status)
VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id
""", (
name,
data.get('abbreviation'),
data.get('description'),
data.get('focus_area_id'),
data.get('sort_order', 99),
data.get('status', 'active')
))
@ -421,7 +438,12 @@ def create_training_type(data: dict, session=Depends(require_auth)):
type_id = cur.fetchone()['id']
conn.commit()
cur.execute("SELECT * FROM training_types WHERE id = %s", (type_id,))
cur.execute("""
SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon
FROM training_types tt
LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id
WHERE tt.id = %s
""", (type_id,))
return r2d(cur.fetchone())
@ -440,6 +462,7 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth)
name = %s,
abbreviation = %s,
description = %s,
focus_area_id = %s,
sort_order = %s,
status = %s,
updated_at = NOW()
@ -448,6 +471,7 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth)
data.get('name'),
data.get('abbreviation'),
data.get('description'),
data.get('focus_area_id'),
data.get('sort_order'),
data.get('status'),
type_id
@ -455,7 +479,12 @@ def update_training_type(type_id: int, data: dict, session=Depends(require_auth)
conn.commit()
cur.execute("SELECT * FROM training_types WHERE id = %s", (type_id,))
cur.execute("""
SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon
FROM training_types tt
LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id
WHERE tt.id = %s
""", (type_id,))
return r2d(cur.fetchone())

View File

@ -24,7 +24,7 @@ export default function AdminCatalogsPage() {
// Training Types (Breitensport, Leistungssport, etc.)
const [trainingTypes, setTrainingTypes] = useState([])
const [editingTT, setEditingTT] = useState(null)
const [newTT, setNewTT] = useState({ name: '', abbreviation: '', description: '' })
const [newTT, setNewTT] = useState({ name: '', abbreviation: '', description: '', focus_area_id: null })
// Skill Categories
const [skillCategories, setSkillCategories] = useState([])
@ -201,7 +201,7 @@ export default function AdminCatalogsPage() {
async function createTrainingType() {
try {
await api.createTrainingType(newTT)
setNewTT({ name: '', abbreviation: '', description: '' })
setNewTT({ name: '', abbreviation: '', description: '', focus_area_id: null })
loadData()
} catch (e) {
setError(e.message)
@ -679,6 +679,19 @@ export default function AdminCatalogsPage() {
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={newTT.focus_area_id || ''}
onChange={e => setNewTT({ ...newTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<button className="btn btn-primary" onClick={createTrainingType}>Anlegen</button>
</div>
@ -712,6 +725,19 @@ export default function AdminCatalogsPage() {
rows={3}
/>
</div>
<div className="form-row">
<label className="form-label">Fokusbereich</label>
<select
className="form-input"
value={editingTT.focus_area_id || ''}
onChange={e => setEditingTT({ ...editingTT, focus_area_id: e.target.value ? parseInt(e.target.value) : null })}
>
<option value="">- Fokusbereich auswählen -</option>
{focusAreas.map(fa => (
<option key={fa.id} value={fa.id}>{fa.icon} {fa.name}</option>
))}
</select>
</div>
<div style={{ display: 'flex', gap: '8px' }}>
<button className="btn btn-primary" onClick={() => updateTrainingType(tt.id, editingTT)}>Speichern</button>
<button className="btn" onClick={() => setEditingTT(null)}>Abbrechen</button>
@ -724,6 +750,12 @@ export default function AdminCatalogsPage() {
{tt.abbreviation && <span style={{ marginLeft: '8px', fontSize: '14px', color: 'var(--text2)' }}>({tt.abbreviation})</span>}
</h3>
{tt.description && <p style={{ margin: '8px 0', color: 'var(--text2)' }}>{tt.description}</p>}
{tt.focus_area_name && (
<p style={{ margin: '8px 0', fontSize: '14px' }}>
<span style={{ color: 'var(--text2)' }}>Fokusbereich: </span>
<span style={{ color: 'var(--accent)' }}>{tt.focus_area_icon} {tt.focus_area_name}</span>
</p>
)}
<div style={{ display: 'flex', gap: '8px', marginTop: '12px' }}>
<button className="btn" onClick={() => setEditingTT(tt)}>Bearbeiten</button>
<button className="btn" onClick={() => deleteTrainingType(tt.id)}>Löschen</button>