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