diff --git a/backend/routers/catalogs.py b/backend/routers/catalogs.py index 50d09cf..d4e7a76 100644 --- a/backend/routers/catalogs.py +++ b/backend/routers/catalogs.py @@ -721,3 +721,254 @@ def delete_target_group(target_group_id: int, session=Depends(require_auth)): conn.commit() return {"ok": True} + + +# ════════════════════════════════════════════════════════════════════════ +# TRAINING STYLE → TARGET GROUPS (M:N Assignments) +# ════════════════════════════════════════════════════════════════════════ + +@router.get("/training-style-target-groups") +def list_training_style_target_groups( + training_style_id: Optional[int] = Query(default=None), + target_group_id: Optional[int] = Query(default=None), + is_primary: Optional[bool] = Query(default=None), + session=Depends(require_auth) +): + """List M:N assignments between training styles and target groups. + + Returns enriched data with training_style_name, target_group_name, + focus_area_name for easy display in Matrix UI. + """ + with get_db() as conn: + cur = get_cursor(conn) + + query = """ + SELECT + tstg.id, + tstg.training_style_id, + tstg.target_group_id, + tstg.is_primary, + tstg.created_at, + ts.name as training_style_name, + ts.focus_area_id, + fa.name as focus_area_name, + tg.name as target_group_name, + tg.min_age, + tg.max_age + FROM training_style_target_groups tstg + LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id + """ + params = [] + where = [] + + if training_style_id is not None: + where.append("tstg.training_style_id = %s") + params.append(training_style_id) + + if target_group_id is not None: + where.append("tstg.target_group_id = %s") + params.append(target_group_id) + + if is_primary is not None: + where.append("tstg.is_primary = %s") + params.append(is_primary) + + if where: + query += " WHERE " + " AND ".join(where) + + query += " ORDER BY fa.sort_order, ts.sort_order, tg.sort_order" + + cur.execute(query, params) + rows = cur.fetchall() + return [r2d(r) for r in rows] + + +@router.post("/training-style-target-groups") +def create_training_style_target_group(data: dict, session=Depends(require_auth)): + """Assign target group to training style (admin only). + + Uses UPSERT logic - if assignment exists, updates is_primary flag. + """ + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zuordnungen erstellen") + + training_style_id = data.get('training_style_id') + target_group_id = data.get('target_group_id') + + if not training_style_id or not target_group_id: + raise HTTPException(400, "training_style_id und target_group_id sind Pflichtfelder") + + with get_db() as conn: + cur = get_cursor(conn) + + # Upsert logic + cur.execute(""" + INSERT INTO training_style_target_groups + (training_style_id, target_group_id, is_primary) + VALUES (%s, %s, %s) + ON CONFLICT (training_style_id, target_group_id) + DO UPDATE SET is_primary = EXCLUDED.is_primary + RETURNING id + """, ( + training_style_id, + target_group_id, + data.get('is_primary', False) + )) + + assignment_id = cur.fetchone()['id'] + conn.commit() + + # Return enriched record + cur.execute(""" + SELECT + tstg.id, + tstg.training_style_id, + tstg.target_group_id, + tstg.is_primary, + tstg.created_at, + ts.name as training_style_name, + ts.focus_area_id, + fa.name as focus_area_name, + tg.name as target_group_name + FROM training_style_target_groups tstg + LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id + WHERE tstg.id = %s + """, (assignment_id,)) + + return r2d(cur.fetchone()) + + +@router.put("/training-style-target-groups/{assignment_id}") +def update_training_style_target_group( + assignment_id: int, + data: dict, + session=Depends(require_auth) +): + """Update M:N assignment (admin only). + + Currently only supports updating is_primary flag. + """ + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zuordnungen bearbeiten") + + with get_db() as conn: + cur = get_cursor(conn) + + cur.execute(""" + UPDATE training_style_target_groups + SET is_primary = %s + WHERE id = %s + """, ( + data.get('is_primary', False), + assignment_id + )) + + conn.commit() + + # Return enriched record + cur.execute(""" + SELECT + tstg.id, + tstg.training_style_id, + tstg.target_group_id, + tstg.is_primary, + tstg.created_at, + ts.name as training_style_name, + ts.focus_area_id, + fa.name as focus_area_name, + tg.name as target_group_name + FROM training_style_target_groups tstg + LEFT JOIN training_styles ts ON tstg.training_style_id = ts.id + LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id + LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id + WHERE tstg.id = %s + """, (assignment_id,)) + + return r2d(cur.fetchone()) + + +@router.delete("/training-style-target-groups/{assignment_id}") +def delete_training_style_target_group(assignment_id: int, session=Depends(require_auth)): + """Remove M:N assignment (admin only).""" + role = session.get('role') + if role not in ['admin', 'superadmin']: + raise HTTPException(403, "Nur Admins dürfen Zuordnungen löschen") + + with get_db() as conn: + cur = get_cursor(conn) + cur.execute("DELETE FROM training_style_target_groups WHERE id = %s", (assignment_id,)) + conn.commit() + + return {"ok": True} + + +@router.get("/training-styles/hierarchy") +def get_training_styles_hierarchy( + status: Optional[str] = Query(default='active'), + session=Depends(require_auth) +): + """Get hierarchical structure: Focus Areas → Training Styles → Target Groups. + + Returns nested structure for Tree-View rendering in Admin UI. + """ + with get_db() as conn: + cur = get_cursor(conn) + + # Get all focus areas + fa_query = "SELECT * FROM focus_areas" + fa_params = [] + + if status: + fa_query += " WHERE status = %s" + fa_params.append(status) + + fa_query += " ORDER BY sort_order, name" + + cur.execute(fa_query, fa_params) + focus_areas = [r2d(r) for r in cur.fetchall()] + + # For each focus area, get training styles with their target groups + for fa in focus_areas: + ts_query = """ + SELECT * FROM training_styles + WHERE focus_area_id = %s + """ + ts_params = [fa['id']] + + if status: + ts_query += " AND status = %s" + ts_params.append(status) + + ts_query += " ORDER BY sort_order, name" + + cur.execute(ts_query, ts_params) + training_styles = [r2d(r) for r in cur.fetchall()] + + # For each training style, get assigned target groups + for ts in training_styles: + cur.execute(""" + SELECT + tg.id, + tg.name, + tg.description, + tg.min_age, + tg.max_age, + tstg.is_primary, + tstg.id as assignment_id + FROM training_style_target_groups tstg + LEFT JOIN target_groups tg ON tstg.target_group_id = tg.id + WHERE tstg.training_style_id = %s + ORDER BY tg.sort_order, tg.name + """, (ts['id'],)) + + ts['target_groups'] = [r2d(r) for r in cur.fetchall()] + + fa['training_styles'] = training_styles + + return focus_areas diff --git a/backend/version.py b/backend/version.py index ecdb529..3b62e38 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.3.3" +APP_VERSION = "0.3.4" BUILD_DATE = "2026-04-23" DB_SCHEMA_VERSION = "20260423" @@ -18,10 +18,25 @@ MODULE_VERSIONS = { "import_wiki": "0.1.0", "admin": "1.0.0", "membership": "1.0.0", - "catalogs": "1.2.0", # Updated: Target Groups CRUD + Admin UI + "catalogs": "1.3.0", # Updated: M:N Zielgruppen-Zuordnung (Migration 009) } CHANGELOG = [ + { + "version": "0.3.4", + "date": "2026-04-23", + "changes": [ + "BREAKING: Migration 009 - Zielgruppen M:N Refactoring", + "DB: target_groups.training_style_id entfernt (jetzt global unabhängig)", + "DB: Neue Junction-Tabelle training_style_target_groups (M:N)", + "API: 5 neue Endpoints für M:N Management (GET/POST/PUT/DELETE + hierarchy)", + "API: GET /training-style-target-groups mit Enrichment (focus_area_name, training_style_name)", + "API: GET /training-styles/hierarchy für Tree-View (verschachtelte Struktur)", + "API: POST /training-style-target-groups mit Upsert-Logik", + "Backward-Compatible: exercise_target_groups weiterhin unterstützt", + "Architecture: Eine Zielgruppe kann mehreren Stilen zugeordnet werden", + ] + }, { "version": "0.3.3", "date": "2026-04-23", diff --git a/frontend/src/version.js b/frontend/src/version.js index dfec193..074ad2d 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -1,6 +1,6 @@ // Shinkan Jinkendo Frontend Version -export const APP_VERSION = "0.3.3" +export const APP_VERSION = "0.3.4" export const BUILD_DATE = "2026-04-23" export const PAGE_VERSIONS = {