""" Catalog Management Endpoints for Shinkan Jinkendo Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc. """ import re from typing import Optional from fastapi import APIRouter, HTTPException, Depends, Query from db import get_db, get_cursor, r2d from auth import require_auth router = APIRouter(prefix="/api", tags=["catalogs"]) def _sql_active_status(column: str, status: Optional[str]) -> tuple[str, list]: """ Filter „active“ schließt Legacy-Zeilen mit status IS NULL ein (sonst leere Dropdowns in der UI). """ if not status: return "", [] if status == "active": return f" ({column} = 'active' OR {column} IS NULL)", [] return f" ({column} = %s)", [status] def _slugify_skill_label(text: str) -> str: t = (text or "").strip().lower() t = re.sub(r"[^a-z0-9äöüß]+", "_", t, flags=re.IGNORECASE) t = re.sub(r"_+", "_", t).strip("_") return (t[:48] or "gruppe") # ════════════════════════════════════════════════════════════════════════ # SKILL MAIN CATEGORIES (Hauptgruppen, z. B. „KARATE Fähigkeiten“) # ════════════════════════════════════════════════════════════════════════ @router.get("/skill-main-categories") def list_skill_main_categories(session=Depends(require_auth)): """Alle Hauptkategorien für den Fähigkeitskatalog (sortiert).""" with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT * FROM skill_main_categories ORDER BY sort_order NULLS LAST, name" ) return [r2d(r) for r in cur.fetchall()] @router.post("/skill-main-categories") def create_skill_main_category(data: dict, session=Depends(require_auth)): role = session.get("role") if role not in ("admin", "superadmin"): raise HTTPException(403, "Nur Admins dürfen Hauptkategorien anlegen") name = (data.get("name") or "").strip() if not name: raise HTTPException(400, "Name ist Pflichtfeld") slug = (data.get("slug") or "").strip() or _slugify_skill_label(name) with get_db() as conn: cur = get_cursor(conn) try: cur.execute( """ INSERT INTO skill_main_categories (name, slug, description, sort_order) VALUES (%s, %s, %s, %s) RETURNING id """, ( name, slug, data.get("description"), data.get("sort_order", 99), ), ) mid = cur.fetchone()["id"] except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): raise HTTPException(409, "Name oder Slug bereits vergeben") from e raise cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (mid,)) return r2d(cur.fetchone()) @router.put("/skill-main-categories/{main_id}") def update_skill_main_category(main_id: int, data: dict, session=Depends(require_auth)): role = session.get("role") if role not in ("admin", "superadmin"): raise HTTPException(403, "Nur Admins dürfen Hauptkategorien bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM skill_main_categories WHERE id = %s", (main_id,)) if not cur.fetchone(): raise HTTPException(404, "Hauptkategorie nicht gefunden") sets = [] vals = [] for key in ("name", "slug", "description", "sort_order"): if key in data: sets.append(f"{key} = %s") vals.append(data[key]) if not sets: cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,)) return r2d(cur.fetchone()) sets.append("updated_at = NOW()") vals.append(main_id) try: cur.execute( f"UPDATE skill_main_categories SET {', '.join(sets)} WHERE id = %s", tuple(vals), ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(409, "Name oder Slug bereits vergeben") from e raise cur.execute("SELECT * FROM skill_main_categories WHERE id = %s", (main_id,)) return r2d(cur.fetchone()) @router.delete("/skill-main-categories/{main_id}") def delete_skill_main_category(main_id: int, session=Depends(require_auth)): role = session.get("role") if role != "superadmin": raise HTTPException(403, "Nur Superadmins dürfen Hauptkategorien löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT COUNT(*) AS c FROM skill_categories WHERE main_category_id = %s", (main_id,), ) if (cur.fetchone() or {}).get("c", 0) > 0: raise HTTPException( 409, "Hauptkategorie hat noch Unterkategorien. Bitte zuerst verschieben oder löschen.", ) cur.execute( "SELECT COUNT(*) AS c FROM skills WHERE main_category_id = %s", (main_id,), ) if (cur.fetchone() or {}).get("c", 0) > 0: raise HTTPException( 409, "Hauptkategorie ist noch Fähigkeiten zugeordnet.", ) cur.execute( "DELETE FROM skill_main_categories WHERE id = %s RETURNING id", (main_id,), ) if not cur.fetchone(): raise HTTPException(404, "Hauptkategorie nicht gefunden") return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # FOCUS AREAS # ════════════════════════════════════════════════════════════════════════ @router.get("/focus-areas") def list_focus_areas( status: Optional[str] = Query(default='active'), session=Depends(require_auth) ): """List all focus areas (public for authenticated users).""" with get_db() as conn: cur = get_cursor(conn) query = "SELECT * FROM focus_areas" params = [] frag, extra = _sql_active_status("status", status) if frag: query += " WHERE" + frag params.extend(extra) query += " ORDER BY sort_order, name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/focus-areas") def create_focus_area(data: dict, session=Depends(require_auth)): """Create new focus area (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fokusbereiche erstellen") name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO focus_areas (name, abbreviation, description, color, icon, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('abbreviation'), data.get('description'), data.get('color'), data.get('icon'), data.get('sort_order', 99), data.get('status', 'active') )) focus_area_id = cur.fetchone()['id'] conn.commit() cur.execute("SELECT * FROM focus_areas WHERE id = %s", (focus_area_id,)) return r2d(cur.fetchone()) @router.put("/focus-areas/{focus_area_id}") def update_focus_area(focus_area_id: int, data: dict, session=Depends(require_auth)): """Update focus area (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fokusbereiche bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" UPDATE focus_areas SET name = %s, abbreviation = %s, description = %s, color = %s, icon = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('abbreviation'), data.get('description'), data.get('color'), data.get('icon'), data.get('sort_order'), data.get('status'), focus_area_id )) conn.commit() cur.execute("SELECT * FROM focus_areas WHERE id = %s", (focus_area_id,)) return r2d(cur.fetchone()) @router.delete("/focus-areas/{focus_area_id}") def delete_focus_area(focus_area_id: int, session=Depends(require_auth)): """Delete focus area (superadmin only).""" role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Fokusbereiche löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM focus_areas WHERE id = %s", (focus_area_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TRAINING STYLES # ════════════════════════════════════════════════════════════════════════ @router.get("/training-styles") def list_training_styles( status: Optional[str] = Query(default='active'), session=Depends(require_auth) ): """List all training styles.""" with get_db() as conn: cur = get_cursor(conn) query = """ SELECT ts.*, ps.name as parent_style_name, fa.name as focus_area_name, fa.icon as focus_area_icon FROM style_directions ts LEFT JOIN style_directions ps ON ts.parent_style_id = ps.id LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id """ params = [] frag, extra = _sql_active_status("ts.status", status) if frag: query += " WHERE" + frag params.extend(extra) query += " ORDER BY ts.sort_order, ts.name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/training-styles") def create_training_style(data: dict, session=Depends(require_auth)): """Create new training style (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingsstile erstellen") name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO style_directions (name, abbreviation, description, focus_area_id, parent_style_id, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('abbreviation'), data.get('description'), data.get('focus_area_id'), data.get('parent_style_id'), data.get('sort_order', 99), data.get('status', 'active') )) style_id = cur.fetchone()['id'] conn.commit() cur.execute(""" SELECT ts.*, ps.name as parent_style_name, fa.name as focus_area_name, fa.icon as focus_area_icon FROM style_directions ts LEFT JOIN style_directions ps ON ts.parent_style_id = ps.id LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id WHERE ts.id = %s """, (style_id,)) return r2d(cur.fetchone()) @router.put("/training-styles/{style_id}") def update_training_style(style_id: int, data: dict, session=Depends(require_auth)): """Update training style (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingsstile bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" UPDATE style_directions SET name = %s, abbreviation = %s, description = %s, focus_area_id = %s, parent_style_id = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('abbreviation'), data.get('description'), data.get('focus_area_id'), data.get('parent_style_id'), data.get('sort_order'), data.get('status'), style_id )) conn.commit() cur.execute(""" SELECT ts.*, ps.name as parent_style_name, fa.name as focus_area_name, fa.icon as focus_area_icon FROM style_directions ts LEFT JOIN style_directions ps ON ts.parent_style_id = ps.id LEFT JOIN focus_areas fa ON ts.focus_area_id = fa.id WHERE ts.id = %s """, (style_id,)) return r2d(cur.fetchone()) @router.delete("/training-styles/{style_id}") def delete_training_style(style_id: int, session=Depends(require_auth)): """Delete training style (superadmin only).""" role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Trainingsstile löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM style_directions WHERE id = %s", (style_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TRAINING CHARACTERS # ════════════════════════════════════════════════════════════════════════ @router.get("/training-characters") def list_training_characters( status: Optional[str] = Query(default='active'), session=Depends(require_auth) ): """List all training characters.""" with get_db() as conn: cur = get_cursor(conn) query = "SELECT * FROM training_characters" params = [] if status: query += " WHERE status = %s" params.append(status) query += " ORDER BY sort_order, name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/training-characters") def create_training_character(data: dict, session=Depends(require_auth)): """Create new training character (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingscharaktere erstellen") name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO training_characters (name, description, sort_order, status) VALUES (%s, %s, %s, %s) RETURNING id """, ( name, data.get('description'), data.get('sort_order', 99), data.get('status', 'active') )) char_id = cur.fetchone()['id'] conn.commit() cur.execute("SELECT * FROM training_characters WHERE id = %s", (char_id,)) return r2d(cur.fetchone()) @router.put("/training-characters/{char_id}") def update_training_character(char_id: int, data: dict, session=Depends(require_auth)): """Update training character (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingscharaktere bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" UPDATE training_characters SET name = %s, description = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('description'), data.get('sort_order'), data.get('status'), char_id )) conn.commit() cur.execute("SELECT * FROM training_characters WHERE id = %s", (char_id,)) return r2d(cur.fetchone()) @router.delete("/training-characters/{char_id}") def delete_training_character(char_id: int, session=Depends(require_auth)): """Delete training character (superadmin only).""" role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Trainingscharaktere löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM training_characters WHERE id = %s", (char_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TRAINING TYPES (Breitensport, Leistungssport, etc.) # ════════════════════════════════════════════════════════════════════════ @router.get("/training-types") def list_training_types( status: Optional[str] = Query(default='active'), focus_area_id: Optional[int] = Query(default=None), session=Depends(require_auth) ): """List all training types. Optional filter by focus_area_id for context-specific types. """ with get_db() as conn: cur = get_cursor(conn) query = """ SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon FROM training_types tt LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id """ params = [] where = [] frag, extra = _sql_active_status("tt.status", status) if frag: where.append(frag.strip()) params.extend(extra) if focus_area_id is not None: where.append("tt.focus_area_id = %s") params.append(focus_area_id) if where: query += " WHERE " + " AND ".join(where) query += " ORDER BY fa.sort_order, tt.sort_order, tt.name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/training-types") def create_training_type(data: dict, session=Depends(require_auth)): """Create new training type (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingsstile erstellen") name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO training_types (name, abbreviation, description, focus_area_id, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('abbreviation'), data.get('description'), data.get('focus_area_id'), data.get('sort_order', 99), data.get('status', 'active') )) type_id = cur.fetchone()['id'] conn.commit() cur.execute(""" SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon FROM training_types tt LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id WHERE tt.id = %s """, (type_id,)) return r2d(cur.fetchone()) @router.put("/training-types/{type_id}") def update_training_type(type_id: int, data: dict, session=Depends(require_auth)): """Update training type (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Trainingsstile bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" UPDATE training_types SET name = %s, abbreviation = %s, description = %s, focus_area_id = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('abbreviation'), data.get('description'), data.get('focus_area_id'), data.get('sort_order'), data.get('status'), type_id )) conn.commit() cur.execute(""" SELECT tt.*, fa.name as focus_area_name, fa.icon as focus_area_icon FROM training_types tt LEFT JOIN focus_areas fa ON tt.focus_area_id = fa.id WHERE tt.id = %s """, (type_id,)) return r2d(cur.fetchone()) @router.delete("/training-types/{type_id}") def delete_training_type(type_id: int, session=Depends(require_auth)): """Delete training type (superadmin only).""" role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Trainingsstile löschen") with get_db() as conn: cur = get_cursor(conn) # Check if assigned to exercises cur.execute(""" SELECT COUNT(*) as count FROM exercise_training_types WHERE training_type_id = %s """, (type_id,)) ex_count = cur.fetchone()['count'] if ex_count > 0: raise HTTPException( 409, f"Trainingsstil kann nicht gelöscht werden: {ex_count} Übung(en) zugeordnet. " "Bitte zuerst alle Zuordnungen entfernen." ) cur.execute("DELETE FROM training_types WHERE id = %s", (type_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # SKILL CATEGORIES # ════════════════════════════════════════════════════════════════════════ @router.get("/skill-categories") def list_skill_categories( status: Optional[str] = Query(default='active'), main_category_id: Optional[int] = Query(default=None), session=Depends(require_auth), ): """List all skill categories (mit Hauptkategorie).""" with get_db() as conn: cur = get_cursor(conn) query = """ SELECT sc.*, pc.name as parent_category_name, mc.id as main_category_ref_id, mc.name as main_category_name, mc.slug as main_category_slug, mc.sort_order as main_category_sort FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id """ params = [] where = [] if status: where.append("sc.status = %s") params.append(status) if main_category_id is not None: where.append("sc.main_category_id = %s") params.append(main_category_id) if where: query += " WHERE " + " AND ".join(where) query += " ORDER BY mc.sort_order NULLS LAST, sc.sort_order NULLS LAST, sc.name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/skill-categories") def create_skill_category(data: dict, session=Depends(require_auth)): """Create new skill category (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche erstellen") name = (data.get("name") or "").strip() if not name: raise HTTPException(400, "Name ist Pflichtfeld") main_category_id = data.get("main_category_id") if main_category_id is not None: main_category_id = int(main_category_id) slug = (data.get("slug") or "").strip() or _slugify_skill_label(name) with get_db() as conn: cur = get_cursor(conn) try: cur.execute( """ INSERT INTO skill_categories ( name, slug, description, parent_category_id, main_category_id, sort_order, status ) VALUES (%s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( name, slug, data.get("description"), data.get("parent_category_id"), main_category_id, data.get("sort_order", 99), data.get("status", "active"), ), ) cat_id = cur.fetchone()["id"] except Exception as e: if "unique" in str(e).lower() or "duplicate" in str(e).lower(): raise HTTPException(409, "Name oder Slug schon vergeben") from e raise cur.execute( """ SELECT sc.*, pc.name as parent_category_name, mc.name as main_category_name, mc.slug as main_category_slug FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id WHERE sc.id = %s """, (cat_id,), ) return r2d(cur.fetchone()) @router.put("/skill-categories/{cat_id}") def update_skill_category(cat_id: int, data: dict, session=Depends(require_auth)): """Update skill category (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fähigkeitsbereiche bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id FROM skill_categories WHERE id = %s", (cat_id,)) if not cur.fetchone(): raise HTTPException(404, "Kategorie nicht gefunden") sets: list = [] vals: list = [] for key in ( "name", "slug", "description", "parent_category_id", "main_category_id", "sort_order", "status", ): if key in data: sets.append(f"{key} = %s") vals.append(data[key]) if sets: sets.append("updated_at = NOW()") vals.append(cat_id) try: cur.execute( f"UPDATE skill_categories SET {', '.join(sets)} WHERE id = %s", tuple(vals), ) except Exception as e: if "unique" in str(e).lower(): raise HTTPException(409, "Name oder Slug schon vergeben") from e raise cur.execute( "SELECT main_category_id FROM skill_categories WHERE id = %s", (cat_id,), ) effective_main = cur.fetchone()["main_category_id"] if effective_main is not None: cur.execute( "UPDATE skills SET main_category_id = %s WHERE category_id = %s", (effective_main, cat_id), ) cur.execute( """ SELECT sc.*, pc.name as parent_category_name, mc.name as main_category_name, mc.slug as main_category_slug FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id LEFT JOIN skill_main_categories mc ON sc.main_category_id = mc.id WHERE sc.id = %s """, (cat_id,), ) return r2d(cur.fetchone()) @router.delete("/skill-categories/{cat_id}") def delete_skill_category(cat_id: int, session=Depends(require_auth)): """Delete skill category (superadmin only).""" role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Fähigkeitsbereiche löschen") with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT COUNT(*) AS c FROM skills WHERE category_id = %s", (cat_id,)) if (cur.fetchone() or {}).get("c", 0) > 0: raise HTTPException( 409, "Kategorie noch Fähigkeiten zugeordnet – bitte zuerst verschieben oder löschen.", ) cur.execute( "SELECT COUNT(*) AS c FROM skill_categories WHERE parent_category_id = %s", (cat_id,), ) if (cur.fetchone() or {}).get("c", 0) > 0: raise HTTPException(409, "Kategorie hat Unterkategorien.") cur.execute("DELETE FROM skill_categories WHERE id = %s RETURNING id", (cat_id,)) if not cur.fetchone(): raise HTTPException(404, "Kategorie nicht gefunden") return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TRAINER FOCUS AREAS (Welcher Trainer arbeitet in welchen Fokusbereichen?) # ════════════════════════════════════════════════════════════════════════ @router.get("/trainer-focus-areas") def list_trainer_focus_areas( profile_id: Optional[int] = Query(default=None), session=Depends(require_auth) ): """List trainer focus area assignments.""" with get_db() as conn: cur = get_cursor(conn) query = """ SELECT tfa.*, fa.name as focus_area_name, fa.abbreviation as focus_area_abbr, p.name as trainer_name FROM trainer_focus_areas tfa LEFT JOIN focus_areas fa ON tfa.focus_area_id = fa.id LEFT JOIN profiles p ON tfa.profile_id = p.id """ params = [] # If not admin, only show own focus areas role = session.get('role') current_profile_id = session['profile_id'] if role not in ['admin', 'superadmin']: query += " WHERE tfa.profile_id = %s" params.append(current_profile_id) elif profile_id: query += " WHERE tfa.profile_id = %s" params.append(profile_id) query += " ORDER BY fa.name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/trainer-focus-areas") def assign_trainer_focus_area(data: dict, session=Depends(require_auth)): """Assign focus area to trainer (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Fokusbereiche zuweisen") profile_id = data.get('profile_id') focus_area_id = data.get('focus_area_id') if not profile_id or not focus_area_id: raise HTTPException(400, "profile_id und focus_area_id sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO trainer_focus_areas (profile_id, focus_area_id, is_primary) VALUES (%s, %s, %s) ON CONFLICT (profile_id, focus_area_id) DO UPDATE SET is_primary = EXCLUDED.is_primary RETURNING id """, ( profile_id, focus_area_id, data.get('is_primary', False) )) tfa_id = cur.fetchone()['id'] conn.commit() cur.execute(""" SELECT tfa.*, fa.name as focus_area_name, p.name as trainer_name FROM trainer_focus_areas tfa LEFT JOIN focus_areas fa ON tfa.focus_area_id = fa.id LEFT JOIN profiles p ON tfa.profile_id = p.id WHERE tfa.id = %s """, (tfa_id,)) return r2d(cur.fetchone()) @router.delete("/trainer-focus-areas/{tfa_id}") def delete_trainer_focus_area(tfa_id: int, session=Depends(require_auth)): """Remove focus area assignment (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Zuweisungen entfernen") with get_db() as conn: cur = get_cursor(conn) cur.execute("DELETE FROM trainer_focus_areas WHERE id = %s", (tfa_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TARGET GROUPS (Zielgruppen) # ════════════════════════════════════════════════════════════════════════ @router.get("/target-groups") def list_target_groups( status: Optional[str] = Query(default='active'), session=Depends(require_auth) ): """List all target groups (global catalog - independent of styles).""" with get_db() as conn: cur = get_cursor(conn) query = "SELECT * FROM target_groups" params = [] where = [] frag, extra = _sql_active_status("status", status) if frag: where.append(frag.strip()) params.extend(extra) if where: query += " WHERE " + " AND ".join(where) query += " ORDER BY sort_order, name" cur.execute(query, params) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/target-groups") def create_target_group(data: dict, session=Depends(require_auth)): """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") name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO target_groups ( name, description, min_age, max_age, sort_order, status ) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('description'), data.get('min_age'), data.get('max_age'), data.get('sort_order', 99), data.get('status', 'active') )) target_group_id = cur.fetchone()['id'] conn.commit() cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,)) return r2d(cur.fetchone()) @router.put("/target-groups/{target_group_id}") def update_target_group(target_group_id: int, data: dict, session=Depends(require_auth)): """Update target group (admin only).""" role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen Zielgruppen bearbeiten") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" UPDATE target_groups SET name = %s, description = %s, min_age = %s, max_age = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('description'), data.get('min_age'), data.get('max_age'), data.get('sort_order'), data.get('status'), target_group_id )) conn.commit() cur.execute("SELECT * FROM target_groups WHERE id = %s", (target_group_id,)) return r2d(cur.fetchone()) @router.delete("/target-groups/{target_group_id}") 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 or training styles. """ role = session.get('role') if role != 'superadmin': raise HTTPException(403, "Nur Superadmins dürfen Zielgruppen löschen") with get_db() as conn: cur = get_cursor(conn) # Check if assigned to exercises cur.execute(""" SELECT COUNT(*) as count FROM exercise_target_groups WHERE target_group_id = %s """, (target_group_id,)) ex_count = cur.fetchone()['count'] # Check if assigned to training styles (M:N) cur.execute(""" SELECT COUNT(*) as count FROM style_direction_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: {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,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # TRAINING STYLE → TARGET GROUPS (M:N Assignments) # ════════════════════════════════════════════════════════════════════════ @router.get("/training-style-target-groups") def list_training_style_target_groups( style_direction_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 style directions and target groups. Returns enriched data with style_direction_name, target_group_name, focus_area_name for easy display in Matrix UI. """ with get_db() as conn: cur = get_cursor(conn) query = """ SELECT sdtg.id, sdtg.style_direction_id, sdtg.target_group_id, sdtg.is_primary, sdtg.created_at, sd.name as style_direction_name, sd.focus_area_id, fa.name as focus_area_name, tg.name as target_group_name, tg.min_age, tg.max_age FROM style_direction_target_groups sdtg LEFT JOIN style_directions sd ON sdtg.style_direction_id = sd.id LEFT JOIN focus_areas fa ON sd.focus_area_id = fa.id LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id """ params = [] where = [] if style_direction_id is not None: where.append("sdtg.style_direction_id = %s") params.append(style_direction_id) if target_group_id is not None: where.append("sdtg.target_group_id = %s") params.append(target_group_id) if is_primary is not None: where.append("sdtg.is_primary = %s") params.append(is_primary) if where: query += " WHERE " + " AND ".join(where) query += " ORDER BY fa.sort_order, sd.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 style direction (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") style_direction_id = data.get('style_direction_id') target_group_id = data.get('target_group_id') if not style_direction_id or not target_group_id: raise HTTPException(400, "style_direction_id und target_group_id sind Pflichtfelder") with get_db() as conn: cur = get_cursor(conn) # Upsert logic cur.execute(""" INSERT INTO style_direction_target_groups (style_direction_id, target_group_id, is_primary) VALUES (%s, %s, %s) ON CONFLICT (style_direction_id, target_group_id) DO UPDATE SET is_primary = EXCLUDED.is_primary RETURNING id """, ( style_direction_id, target_group_id, data.get('is_primary', False) )) assignment_id = cur.fetchone()['id'] conn.commit() # Return enriched record cur.execute(""" SELECT sdtg.id, sdtg.style_direction_id, sdtg.target_group_id, sdtg.is_primary, sdtg.created_at, sd.name as style_direction_name, sd.focus_area_id, fa.name as focus_area_name, tg.name as target_group_name FROM style_direction_target_groups sdtg LEFT JOIN style_directions sd ON sdtg.style_direction_id = sd.id LEFT JOIN focus_areas fa ON sd.focus_area_id = fa.id LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id WHERE sdtg.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 style_direction_target_groups SET is_primary = %s WHERE id = %s """, ( data.get('is_primary', False), assignment_id )) conn.commit() # Return enriched record cur.execute(""" SELECT sdtg.id, sdtg.style_direction_id, sdtg.target_group_id, sdtg.is_primary, sdtg.created_at, sd.name as style_direction_name, sd.focus_area_id, fa.name as focus_area_name, tg.name as target_group_name FROM style_direction_target_groups sdtg LEFT JOIN style_directions sd ON sdtg.style_direction_id = sd.id LEFT JOIN focus_areas fa ON sd.focus_area_id = fa.id LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id WHERE sdtg.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 style_direction_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 → Style Directions → 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 style directions with their target groups for fa in focus_areas: sd_query = """ SELECT * FROM style_directions WHERE focus_area_id = %s """ sd_params = [fa['id']] if status: sd_query += " AND status = %s" sd_params.append(status) sd_query += " ORDER BY sort_order, name" cur.execute(sd_query, sd_params) style_directions = [r2d(r) for r in cur.fetchall()] # For each style direction, get assigned target groups for sd in style_directions: cur.execute(""" SELECT tg.id, tg.name, tg.description, tg.min_age, tg.max_age, sdtg.is_primary, sdtg.id as assignment_id FROM style_direction_target_groups sdtg LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id WHERE sdtg.style_direction_id = %s ORDER BY tg.sort_order, tg.name """, (sd['id'],)) sd['target_groups'] = [r2d(r) for r in cur.fetchall()] fa['style_directions'] = style_directions return focus_areas # ════════════════════════════════════════════════════════════════════════ # TRAINER CONTEXTS (Fokussierte Trainer-Ansichten) # ════════════════════════════════════════════════════════════════════════ @router.get("/trainer-contexts") def list_trainer_contexts(session=Depends(require_auth)): """List all trainer contexts for the current user. Returns enriched data with focus_area_name, style_direction_name, training_type_name. """ profile_id = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) cur.execute(""" SELECT tc.*, fa.name as focus_area_name, fa.icon as focus_area_icon, sd.name as style_direction_name, tt.name as training_type_name FROM trainer_contexts tc LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id LEFT JOIN training_types tt ON tc.training_type_id = tt.id WHERE tc.profile_id = %s ORDER BY tc.sort_order, tc.name """, (profile_id,)) rows = cur.fetchall() return [r2d(r) for r in rows] @router.post("/trainer-contexts") def create_trainer_context(data: dict, session=Depends(require_auth)): """Create new trainer context for the current user.""" profile_id = session['profile_id'] name = data.get('name') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO trainer_contexts ( profile_id, name, focus_area_id, style_direction_id, training_type_id, is_style_independent, description, sort_order, is_active ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( profile_id, name, data.get('focus_area_id'), data.get('style_direction_id'), data.get('training_type_id'), data.get('is_style_independent', False), data.get('description'), data.get('sort_order', 99), data.get('is_active', True) )) context_id = cur.fetchone()['id'] conn.commit() # Return enriched record cur.execute(""" SELECT tc.*, fa.name as focus_area_name, sd.name as style_direction_name, tt.name as training_type_name FROM trainer_contexts tc LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id LEFT JOIN training_types tt ON tc.training_type_id = tt.id WHERE tc.id = %s """, (context_id,)) return r2d(cur.fetchone()) @router.put("/trainer-contexts/{context_id}") def update_trainer_context(context_id: int, data: dict, session=Depends(require_auth)): """Update trainer context (own contexts only).""" profile_id = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Verify ownership cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Kontext nicht gefunden") if row['profile_id'] != profile_id: raise HTTPException(403, "Zugriff verweigert") cur.execute(""" UPDATE trainer_contexts SET name = %s, focus_area_id = %s, style_direction_id = %s, training_type_id = %s, is_style_independent = %s, description = %s, sort_order = %s, is_active = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('focus_area_id'), data.get('style_direction_id'), data.get('training_type_id'), data.get('is_style_independent', False), data.get('description'), data.get('sort_order'), data.get('is_active', True), context_id )) conn.commit() # Return enriched record cur.execute(""" SELECT tc.*, fa.name as focus_area_name, sd.name as style_direction_name, tt.name as training_type_name FROM trainer_contexts tc LEFT JOIN focus_areas fa ON tc.focus_area_id = fa.id LEFT JOIN style_directions sd ON tc.style_direction_id = sd.id LEFT JOIN training_types tt ON tc.training_type_id = tt.id WHERE tc.id = %s """, (context_id,)) return r2d(cur.fetchone()) @router.delete("/trainer-contexts/{context_id}") def delete_trainer_context(context_id: int, session=Depends(require_auth)): """Delete trainer context (own contexts only).""" profile_id = session['profile_id'] with get_db() as conn: cur = get_cursor(conn) # Verify ownership cur.execute("SELECT profile_id FROM trainer_contexts WHERE id = %s", (context_id,)) row = cur.fetchone() if not row: raise HTTPException(404, "Kontext nicht gefunden") if row['profile_id'] != profile_id: raise HTTPException(403, "Zugriff verweigert") cur.execute("DELETE FROM trainer_contexts WHERE id = %s", (context_id,)) conn.commit() return {"ok": True} # ════════════════════════════════════════════════════════════════════════ # HIERARCHICAL CATALOG VIEW (Admin UI Tree) # ════════════════════════════════════════════════════════════════════════ @router.get("/admin/hierarchy") def get_admin_hierarchy(session=Depends(require_auth)): """ Get complete hierarchical catalog structure for admin tree view. Returns: [ { "id": 1, "name": "Karate", "icon": "🥋", "description": "...", "style_directions": [ { "id": 10, "name": "Shotokan", "abbreviation": "SKA", "description": "...", "target_groups": [ {"id": 100, "name": "Kinder", "is_primary": true} ] } ], "training_types": [ { "id": 20, "name": "Breitensport", "abbreviation": "BS", "description": "..." } ] } ] """ role = session.get('role') if role not in ['admin', 'superadmin']: raise HTTPException(403, "Nur Admins dürfen die Hierarchie abrufen") with get_db() as conn: cur = get_cursor(conn) # 1. Get all focus areas cur.execute(""" SELECT id, name, icon, description, sort_order, status FROM focus_areas WHERE status = 'active' ORDER BY sort_order, name """) focus_areas = [r2d(r) for r in cur.fetchall()] # 2. Get all style directions with their target group assignments cur.execute(""" SELECT sd.id, sd.name, sd.abbreviation, sd.description, sd.focus_area_id, sd.sort_order, json_agg( json_build_object( 'id', tg.id, 'name', tg.name, 'is_primary', sdtg.is_primary ) ORDER BY tg.name ) FILTER (WHERE tg.id IS NOT NULL) as target_groups FROM style_directions sd LEFT JOIN style_direction_target_groups sdtg ON sd.id = sdtg.style_direction_id LEFT JOIN target_groups tg ON sdtg.target_group_id = tg.id WHERE sd.status = 'active' GROUP BY sd.id, sd.name, sd.abbreviation, sd.description, sd.focus_area_id, sd.sort_order ORDER BY sd.sort_order, sd.name """) style_directions = [r2d(r) for r in cur.fetchall()] # 3. Get all training types cur.execute(""" SELECT id, name, abbreviation, description, focus_area_id, sort_order FROM training_types WHERE status = 'active' ORDER BY sort_order, name """) training_types = [r2d(r) for r in cur.fetchall()] # 4. Build hierarchy for fa in focus_areas: fa_id = fa['id'] # Attach style directions fa['style_directions'] = [ sd for sd in style_directions if sd['focus_area_id'] == fa_id ] # Attach training types fa['training_types'] = [ tt for tt in training_types if tt['focus_area_id'] == fa_id ] return focus_areas