feat: Refactor target groups to M:N relationship and update related endpoints
This commit is contained in:
parent
2186bb3a69
commit
2a5f06a8f5
80
backend/migrations/009_target_groups_mn_refactor.sql
Normal file
80
backend/migrations/009_target_groups_mn_refactor.sql
Normal 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
|
||||||
|
|
@ -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,))
|
||||||
|
|
|
||||||
4
test-results/.last-run.json
Normal file
4
test-results/.last-run.json
Normal file
|
|
@ -0,0 +1,4 @@
|
||||||
|
{
|
||||||
|
"status": "failed",
|
||||||
|
"failedTests": []
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user