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") @router.get("/target-groups")
def list_target_groups( def list_target_groups(
status: Optional[str] = Query(default='active'), status: Optional[str] = Query(default='active'),
training_style_id: Optional[int] = Query(default=None),
session=Depends(require_auth) 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
query = """ query = "SELECT * FROM target_groups"
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
"""
params = [] params = []
where = [] where = []
if status: if status:
where.append("tg.status = %s") where.append("status = %s")
params.append(status) params.append(status)
if training_style_id:
where.append("tg.training_style_id = %s")
params.append(training_style_id)
if where: if where:
query += " WHERE " + " AND ".join(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) cur.execute(query, params)
rows = cur.fetchall() rows = cur.fetchall()
@ -623,7 +611,7 @@ def list_target_groups(
@router.post("/target-groups") @router.post("/target-groups")
def create_target_group(data: dict, session=Depends(require_auth)): 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') role = session.get('role')
if role not in ['admin', 'superadmin']: if role not in ['admin', 'superadmin']:
raise HTTPException(403, "Nur Admins dürfen Zielgruppen erstellen") 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(""" cur.execute("""
INSERT INTO target_groups ( INSERT INTO target_groups (
training_style_id, name, description, name, description, min_age, max_age, sort_order, status
min_age, max_age, sort_order, status
) )
VALUES (%s, %s, %s, %s, %s, %s, %s) VALUES (%s, %s, %s, %s, %s, %s)
RETURNING id RETURNING id
""", ( """, (
data.get('training_style_id'),
name, name,
data.get('description'), data.get('description'),
data.get('min_age'), data.get('min_age'),
@ -655,16 +641,7 @@ def create_target_group(data: dict, session=Depends(require_auth)):
target_group_id = cur.fetchone()['id'] target_group_id = cur.fetchone()['id']
conn.commit() conn.commit()
# Return with hierarchical context cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,))
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,))
return r2d(cur.fetchone()) return r2d(cur.fetchone())
@ -680,7 +657,6 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir
cur.execute(""" cur.execute("""
UPDATE target_groups SET UPDATE target_groups SET
training_style_id = %s,
name = %s, name = %s,
description = %s, description = %s,
min_age = %s, min_age = %s,
@ -690,7 +666,6 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir
updated_at = NOW() updated_at = NOW()
WHERE id = %s WHERE id = %s
""", ( """, (
data.get('training_style_id'),
data.get('name'), data.get('name'),
data.get('description'), data.get('description'),
data.get('min_age'), data.get('min_age'),
@ -702,16 +677,7 @@ def update_target_group(target_group_id: int, data: dict, session=Depends(requir
conn.commit() conn.commit()
# Return with hierarchical context cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,))
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,))
return r2d(cur.fetchone()) 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)): def delete_target_group(target_group_id: int, session=Depends(require_auth)):
"""Delete target group (superadmin only). """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') role = session.get('role')
if role != 'superadmin': if role != 'superadmin':
@ -734,13 +700,21 @@ def delete_target_group(target_group_id: int, session=Depends(require_auth)):
FROM exercise_target_groups FROM exercise_target_groups
WHERE target_group_id = %s WHERE target_group_id = %s
""", (target_group_id,)) """, (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( raise HTTPException(
409, 409,
f"Zielgruppe kann nicht gelöscht werden: {count} Übung(en) zugeordnet. " f"Zielgruppe kann nicht gelöscht werden: {ex_count} Übung(en), {style_count} Stil(e) zugeordnet. "
"Bitte zuerst alle Zuordnungen entfernen oder umrouten." "Bitte zuerst alle Zuordnungen entfernen."
) )
cur.execute("DELETE FROM target_groups WHERE id = %s", (target_group_id,)) cur.execute("DELETE FROM target_groups WHERE id = %s", (target_group_id,))

View File

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