shinkan-jinkendo/backend/routers/catalogs.py
Lars 0ad096e483
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) Has been cancelled
feat: enhance SQL query handling and UI components for exercise management
- Introduced a new utility function to streamline SQL query construction for active status filtering, improving code reusability across multiple endpoints.
- Updated existing query logic in the catalogs and skills routers to utilize the new utility function, ensuring consistent handling of active status.
- Refactored the ExerciseFormPage to remove deprecated age group handling, simplifying the form structure.
- Enhanced the RichTextEditor component with improved link handling and formatting options for better user experience.
- Updated API utility functions to support new AI features for exercise suggestions and regeneration, expanding the capabilities of the exercise management system.
2026-04-27 15:01:47 +02:00

1623 lines
56 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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