From 2a5f06a8f5810db67ff6d1823412507307f8d870 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 23 Apr 2026 09:27:13 +0200 Subject: [PATCH] feat: Refactor target groups to M:N relationship and update related endpoints --- .../009_target_groups_mn_refactor.sql | 80 +++++++++++++++++++ backend/routers/catalogs.py | 70 +++++----------- test-results/.last-run.json | 4 + 3 files changed, 106 insertions(+), 48 deletions(-) create mode 100644 backend/migrations/009_target_groups_mn_refactor.sql create mode 100644 test-results/.last-run.json diff --git a/backend/migrations/009_target_groups_mn_refactor.sql b/backend/migrations/009_target_groups_mn_refactor.sql new file mode 100644 index 0000000..2a242ce --- /dev/null +++ b/backend/migrations/009_target_groups_mn_refactor.sql @@ -0,0 +1,80 @@ +-- Migration 009: Zielgruppen M:N Refactoring +-- Erstellt: 2026-04-23 +-- Beschreibung: Umstellung von hierarchisch (training_style_id FK) zu M:N (Junction-Tabelle) +-- Grund: Zielgruppen sollten Global sein und mehreren Stilen zugeordnet werden können + +-- ============================================================================ +-- PHASE 1: Neue M:N Junction-Tabelle erstellen +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS training_style_target_groups ( + id SERIAL PRIMARY KEY, + training_style_id INT REFERENCES training_styles(id) ON DELETE CASCADE, + target_group_id INT REFERENCES target_groups(id) ON DELETE CASCADE, + is_primary BOOLEAN DEFAULT false, + created_at TIMESTAMP DEFAULT NOW(), + UNIQUE(training_style_id, target_group_id) +); + +CREATE INDEX IF NOT EXISTS idx_training_style_target_groups_style + ON training_style_target_groups(training_style_id); +CREATE INDEX IF NOT EXISTS idx_training_style_target_groups_target + ON training_style_target_groups(target_group_id); +CREATE INDEX IF NOT EXISTS idx_training_style_target_groups_primary + ON training_style_target_groups(is_primary); + +-- ============================================================================ +-- PHASE 2: Daten-Migration - Alte Zuordnungen zu M:N übernehmen +-- ============================================================================ + +INSERT INTO training_style_target_groups (training_style_id, target_group_id, is_primary) +SELECT training_style_id, id, true +FROM target_groups +WHERE training_style_id IS NOT NULL +ON CONFLICT (training_style_id, target_group_id) DO NOTHING; + +-- ============================================================================ +-- PHASE 3: Alte target_groups.training_style_id Column entfernen +-- ============================================================================ + +DO $$ +BEGIN + -- Prüfe ob training_style_id noch existiert + IF EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name = 'target_groups' AND column_name = 'training_style_id' + ) THEN + -- Entferne Foreign Key Constraint + ALTER TABLE target_groups DROP CONSTRAINT IF EXISTS target_groups_training_style_id_fkey CASCADE; + + -- Entferne die Spalte + ALTER TABLE target_groups DROP COLUMN training_style_id CASCADE; + END IF; +END $$; + +-- ============================================================================ +-- PHASE 4: Alte Index aufräumen (falls vorhanden) +-- ============================================================================ + +DROP INDEX IF EXISTS idx_target_groups_style; + +-- ============================================================================ +-- PHASE 5: Validierung - Zielgruppen sind jetzt unabhängig +-- ============================================================================ + +-- Nach dieser Migration: +-- - target_groups hat KEINE training_style_id FK mehr +-- - Zielgruppen sind vollständig unabhängig global definiert +-- - Zuordnung zu Stilen erfolgt über training_style_target_groups (M:N) +-- - Eine Zielgruppe kann mehreren Stilen zugeordnet sein +-- - Beispiel: "Breitensportler" → Shotokan, Goju-Ryu, Wado-Ryu + +-- ============================================================================ +-- PHASE 6: Rückwärtskompatibilität +-- ============================================================================ + +-- Falls Übungen direkt auf target_group_id referenzieren (exercise_target_groups), +-- funktioniert das weiterhin ohne Probleme (keine Schema-Änderung auf exercises). +-- Die exercise_target_groups Tabelle bleibt unverändert. +-- Neue Logik: Exercise kann Zielgruppen beliebig zuordnen +-- Zielgruppen sind unabhängig von Stilen definiert diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index f52c0b4..50d09cf 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -585,36 +585,24 @@ def delete_trainer_focus_area(tfa_id: int, session=Depends(require_auth)): @router.get("/target-groups") def list_target_groups( status: Optional[str] = Query(default='active'), - training_style_id: Optional[int] = Query(default=None), session=Depends(require_auth) ): - """List all target groups with hierarchical context.""" + """List all target groups (global catalog - independent of styles).""" with get_db() as conn: cur = get_cursor(conn) - query = """ - SELECT tg.*, - ts.name as training_style_name, - fa.name as focus_area_name - FROM target_groups tg - LEFT JOIN training_styles ts ON tg.training_style_id = ts.id - LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id - """ + query = "SELECT * FROM target_groups" params = [] where = [] if status: - where.append("tg.status = %s") + where.append("status = %s") params.append(status) - if training_style_id: - where.append("tg.training_style_id = %s") - params.append(training_style_id) - if where: query += " WHERE " + " AND ".join(where) - query += " ORDER BY fa.name, ts.name, tg.sort_order, tg.name" + query += " ORDER BY sort_order, name" cur.execute(query, params) rows = cur.fetchall() @@ -623,7 +611,7 @@ def list_target_groups( @router.post("/target-groups") def create_target_group(data: dict, session=Depends(require_auth)): - """Create new target group (admin only).""" + """Create new target group (admin only, global catalog).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Zielgruppen erstellen") @@ -637,13 +625,11 @@ def create_target_group(data: dict, session=Depends(require_auth)): cur.execute(""" INSERT INTO target_groups ( - training_style_id, name, description, - min_age, max_age, sort_order, status + name, description, min_age, max_age, sort_order, status ) - VALUES (%s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( - data.get('training_style_id'), name, data.get('description'), data.get('min_age'), @@ -655,16 +641,7 @@ def create_target_group(data: dict, session=Depends(require_auth)): target_group_id = cur.fetchone()['id'] conn.commit() - # Return with hierarchical context - cur.execute(""" - SELECT tg.*, - ts.name as training_style_name, - fa.name as focus_area_name - FROM target_groups tg - LEFT JOIN training_styles ts ON tg.training_style_id = ts.id - LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id - WHERE tg.id = %s - """, (target_group_id,)) + cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,)) return r2d(cur.fetchone()) @@ -680,7 +657,6 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir cur.execute(""" UPDATE target_groups SET - training_style_id = %s, name = %s, description = %s, min_age = %s, @@ -690,7 +666,6 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir updated_at = NOW() WHERE id = %s """, ( - data.get('training_style_id'), data.get('name'), data.get('description'), data.get('min_age'), @@ -702,16 +677,7 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir conn.commit() - # Return with hierarchical context - cur.execute(""" - SELECT tg.*, - ts.name as training_style_name, - fa.name as focus_area_name - FROM target_groups tg - LEFT JOIN training_styles ts ON tg.training_style_id = ts.id - LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id - WHERE tg.id = %s - """, (target_group_id,)) + cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,)) return r2d(cur.fetchone()) @@ -719,7 +685,7 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir def delete_target_group(target_group_id: int, session=Depends(require_auth)): """Delete target group (superadmin only). - Fails if target group is assigned to any exercises (CASCADE protection). + Fails if target group is assigned to any exercises or training styles. """ role = session.get('role') if role != 'superadmin': @@ -734,13 +700,21 @@ def delete_target_group(target_group_id: int, session=Depends(require_auth)): FROM exercise_target_groups WHERE target_group_id = %s """, (target_group_id,)) - count = cur.fetchone()['count'] + ex_count = cur.fetchone()['count'] - if count > 0: + # Check if assigned to training styles (M:N) + cur.execute(""" + SELECT COUNT(*) as count + FROM training_style_target_groups + WHERE target_group_id = %s + """, (target_group_id,)) + style_count = cur.fetchone()['count'] + + if ex_count > 0 or style_count > 0: raise HTTPException( 409, - f"Zielgruppe kann nicht gelöscht werden: {count} Übung(en) zugeordnet. " - "Bitte zuerst alle Zuordnungen entfernen oder umrouten." + f"Zielgruppe kann nicht gelöscht werden: {ex_count} Übung(en), {style_count} Stil(e) zugeordnet. " + "Bitte zuerst alle Zuordnungen entfernen." ) cur.execute("DELETE FROM target_groups WHERE id = %s", (target_group_id,)) diff --git a/test-results/.last-run.json b/test-results/.last-run.json new file mode 100644 index 0000000..5fca3f8 --- /dev/null +++ b/test-results/.last-run.json @@ -0,0 +1,4 @@ +{ + "status": "failed", + "failedTests": [] +} \ No newline at end of file