""" Catalog Management Endpoints for Shinkan Jinkendo Admin-verwaltbare Stammdaten für Übungen, Fokusbereiche, Stile, etc. """ 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"]) # ════════════════════════════════════════════════════════════════════════ # 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 = [] 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("/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 FROM training_styles ts LEFT JOIN training_styles ps ON ts.parent_style_id = ps.id """ params = [] if status: query += " WHERE ts.status = %s" params.append(status) 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 training_styles (name, abbreviation, description, parent_style_id, sort_order, status) VALUES (%s, %s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('abbreviation'), data.get('description'), 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 FROM training_styles ts LEFT JOIN training_styles ps ON ts.parent_style_id = ps.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 training_styles SET name = %s, abbreviation = %s, description = %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('parent_style_id'), data.get('sort_order'), data.get('status'), style_id )) conn.commit() cur.execute(""" SELECT ts.*, ps.name as parent_style_name FROM training_styles ts LEFT JOIN training_styles ps ON ts.parent_style_id = ps.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 training_styles 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} # ════════════════════════════════════════════════════════════════════════ # SKILL CATEGORIES # ════════════════════════════════════════════════════════════════════════ @router.get("/skill-categories") def list_skill_categories( status: Optional[str] = Query(default='active'), session=Depends(require_auth) ): """List all skill categories.""" with get_db() as conn: cur = get_cursor(conn) query = """ SELECT sc.*, pc.name as parent_category_name FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.id """ params = [] if status: query += " WHERE sc.status = %s" params.append(status) query += " ORDER BY sc.sort_order, 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') if not name: raise HTTPException(400, "Name ist Pflichtfeld") with get_db() as conn: cur = get_cursor(conn) cur.execute(""" INSERT INTO skill_categories (name, description, parent_category_id, sort_order, status) VALUES (%s, %s, %s, %s, %s) RETURNING id """, ( name, data.get('description'), data.get('parent_category_id'), data.get('sort_order', 99), data.get('status', 'active') )) cat_id = cur.fetchone()['id'] conn.commit() cur.execute(""" SELECT sc.*, pc.name as parent_category_name FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.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(""" UPDATE skill_categories SET name = %s, description = %s, parent_category_id = %s, sort_order = %s, status = %s, updated_at = NOW() WHERE id = %s """, ( data.get('name'), data.get('description'), data.get('parent_category_id'), data.get('sort_order'), data.get('status'), cat_id )) conn.commit() cur.execute(""" SELECT sc.*, pc.name as parent_category_name FROM skill_categories sc LEFT JOIN skill_categories pc ON sc.parent_category_id = pc.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("DELETE FROM skill_categories WHERE id = %s", (cat_id,)) conn.commit() 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 = [] if status: where.append("status = %s") params.append(status) 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 training_style_target_groups WHERE target_group_id = %s """, (target_group_id,)) style_count = cur.fetchone()['count'] if ex_count > 0 or style_count > 0: raise HTTPException( 409, f"Zielgruppe kann nicht gelöscht werden: {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( 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