feat: Refactor target groups to M:N relationship and update related endpoints
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 5s
Test Suite / playwright-tests (push) Failing after 1m55s

This commit is contained in:
Lars 2026-04-23 09:27:13 +02:00
parent 2186bb3a69
commit 2a5f06a8f5
3 changed files with 106 additions and 48 deletions

View File

@ -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

View File

@ -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,))

View File

@ -0,0 +1,4 @@
{
"status": "failed",
"failedTests": []
}