""" Superadmin API: Übersicht und Moderation nutzerangelegter Inhalte (inkl. private). # ACCESS_LAYER exempt: Plattform-weites Superadmin-Werkzeug ohne TenantContext. Siehe ACCESS_LAYER_ENDPOINT_AUDIT.md. """ from __future__ import annotations from typing import Any, Dict, List, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query from pydantic import BaseModel, Field, model_validator from auth import require_auth from club_tenancy import is_superadmin from db import get_db, get_cursor, r2d from media_lifecycle import superadmin_hard_delete_media_asset router = APIRouter(prefix="/api/admin/user-content", tags=["admin_user_content"]) _VALID_VISIBILITY = frozenset({"private", "club", "official"}) _VALID_EXERCISE_STATUS = frozenset({"draft", "in_review", "approved", "archived"}) _VALID_MATURITY_STATUS = frozenset({"draft", "active", "archived"}) _VALID_MEDIA_RIGHTS = frozenset({"legacy_unreviewed", "declared", "blocked"}) _VALID_MEDIA_LIFECYCLE = frozenset({"active", "trash_soft", "trash_hidden"}) _MAX_ITEMS_LIMIT = 100 ContentType = Literal[ "exercise", "training_module", "framework_program", "progression_graph", "plan_template", "maturity_model", "media_asset", ] _CONTENT_SPECS: Dict[str, Dict[str, Any]] = { "exercise": { "label": "Übung", "table": "exercises", "creator_col": "created_by", "title_col": "title", "status_col": "status", "visibility_col": "visibility", "club_col": "club_id", "has_status": True, "has_visibility": True, "status_values": sorted(_VALID_EXERCISE_STATUS), }, "training_module": { "label": "Trainingsmodul", "table": "training_modules", "creator_col": "created_by", "title_col": "title", "status_col": None, "visibility_col": "visibility", "club_col": "club_id", "has_status": False, "has_visibility": True, "status_values": [], }, "framework_program": { "label": "Rahmenprogramm", "table": "training_framework_programs", "creator_col": "created_by", "title_col": "title", "status_col": None, "visibility_col": "visibility", "club_col": "club_id", "has_status": False, "has_visibility": True, "status_values": [], }, "progression_graph": { "label": "Progressionspfad", "table": "exercise_progression_graphs", "creator_col": "created_by", "title_col": "name", "status_col": None, "visibility_col": "visibility", "club_col": "club_id", "has_status": False, "has_visibility": True, "status_values": [], }, "plan_template": { "label": "Trainingsvorlage", "table": "training_plan_templates", "creator_col": "created_by", "title_col": "name", "status_col": None, "visibility_col": "visibility", "club_col": "club_id", "has_status": False, "has_visibility": True, "status_values": [], }, "maturity_model": { "label": "Reifegradmodell", "table": "maturity_models", "creator_col": "created_by", "title_col": "name", "status_col": "status", "visibility_col": None, "club_col": "club_id", "has_status": True, "has_visibility": False, "status_values": sorted(_VALID_MATURITY_STATUS), }, "media_asset": { "label": "Medium", "table": "media_assets", "creator_col": "uploaded_by_profile_id", "title_col": "original_filename", "status_col": "rights_status", "visibility_col": "visibility", "club_col": "club_id", "extra_col": "lifecycle_state", "has_status": True, "has_visibility": True, "status_values": sorted(_VALID_MEDIA_RIGHTS), }, } def _require_superadmin(session: dict) -> dict: role = (session.get("role") or "").strip().lower() if not is_superadmin(role): raise HTTPException(status_code=403, detail="Nur Superadmins") return session def _spec(content_type: str) -> Dict[str, Any]: key = (content_type or "").strip().lower() spec = _CONTENT_SPECS.get(key) if not spec: raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp") return spec def _types_for_filters( content_type: Optional[str], status: Optional[str], ) -> List[str]: if content_type and content_type != "all": return [content_type] types = list(_CONTENT_SPECS.keys()) if status: types = [t for t in types if _CONTENT_SPECS[t].get("has_status")] return types def _build_type_select(spec: Dict[str, Any], content_type: str) -> str: title = spec["title_col"] creator = spec["creator_col"] status = spec.get("status_col") visibility = spec.get("visibility_col") club = spec.get("club_col") extra = spec.get("extra_col") status_sql = f"t.{status}" if status else "NULL" vis_sql = f"t.{visibility}" if visibility else "NULL" club_sql = f"t.{club}" if club else "NULL" extra_sql = f"t.{extra}" if extra else "NULL" return f""" SELECT '{content_type}' AS content_type, t.id, t.{title} AS title, t.{creator} AS profile_id, {status_sql} AS status, {vis_sql} AS visibility, {club_sql} AS club_id, {extra_sql} AS extra_status, t.created_at, t.updated_at FROM {spec['table']} t """ def _append_filters( where: List[str], params: List[Any], *, spec: Dict[str, Any], profile_id: Optional[int], visibility: Optional[str], status: Optional[str], search: Optional[str], ) -> None: creator = spec["creator_col"] if profile_id is not None: where.append(f"t.{creator} = %s") params.append(profile_id) if visibility and visibility != "all": vis_col = spec.get("visibility_col") if vis_col: where.append(f"t.{vis_col} = %s") params.append(visibility) if status: st_col = spec.get("status_col") if st_col: where.append(f"t.{st_col} = %s") params.append(status) if search: title_col = spec["title_col"] where.append(f"t.{title_col} ILIKE %s") params.append(f"%{search}%") def _exercise_delete_usage_message(cur, exercise_id: int) -> str: cur.execute( """ SELECT (SELECT COUNT(*)::int FROM exercise_block_items WHERE exercise_id = %s) AS block_items, (SELECT COUNT(*)::int FROM training_unit_section_items WHERE exercise_id = %s) AS section_items, (SELECT COUNT(*)::int FROM exercise_progression_edges WHERE from_exercise_id = %s OR to_exercise_id = %s) AS prog_edges """, (exercise_id, exercise_id, exercise_id, exercise_id), ) row = r2d(cur.fetchone() or {}) parts = [] if int(row.get("block_items") or 0): parts.append(f"{row['block_items']}× in Übungsblöcken") if int(row.get("section_items") or 0): parts.append(f"{row['section_items']}× in Trainingsplänen oder Rahmenabläufen") if int(row.get("prog_edges") or 0): parts.append(f"{row['prog_edges']}× in Progressionsgraphen (Kanten)") if not parts: return "" return ( "Die Übung wird noch verwendet und kann nicht gelöscht werden. " "Bitte auf „archiviert“ setzen. Verwendung: " + ", ".join(parts) + "." ) class UserContentPatchBody(BaseModel): status: Optional[str] = None visibility: Optional[str] = None lifecycle_state: Optional[str] = None @model_validator(mode="after") def at_least_one_field(self): if self.status is None and self.visibility is None and self.lifecycle_state is None: raise ValueError("Mindestens eines der Felder status, visibility oder lifecycle_state angeben") return self @router.get("/meta") def get_user_content_meta(session: dict = Depends(require_auth)): """Metadaten zu unterstützten Inhaltstypen.""" _require_superadmin(session) types = [] for key, spec in _CONTENT_SPECS.items(): types.append( { "id": key, "label": spec["label"], "has_status": spec["has_status"], "has_visibility": spec["has_visibility"], "status_values": spec.get("status_values") or [], } ) return {"content_types": types} @router.get("/users-summary") def list_users_content_summary(session: dict = Depends(require_auth)): """Anzahl angelegter Inhalte je Nutzer (alle Sichtbarkeiten).""" _require_superadmin(session) count_exprs: List[str] = [] for key, spec in _CONTENT_SPECS.items(): creator = spec["creator_col"] count_exprs.append( f"(SELECT COUNT(*)::int FROM {spec['table']} WHERE {creator} = p.id) AS {key}_count" ) counts_sql = ",\n ".join(count_exprs) with get_db() as conn: cur = get_cursor(conn) cur.execute( f""" SELECT p.id, p.name, p.email, p.role, p.created_at, {counts_sql}, ( {" + ".join(f"COALESCE((SELECT COUNT(*)::int FROM {spec['table']} WHERE {spec['creator_col']} = p.id), 0)" for spec in _CONTENT_SPECS.values())} ) AS total_count FROM profiles p WHERE EXISTS ( SELECT 1 FROM exercises e WHERE e.created_by = p.id UNION ALL SELECT 1 FROM training_modules tm WHERE tm.created_by = p.id UNION ALL SELECT 1 FROM training_framework_programs fp WHERE fp.created_by = p.id UNION ALL SELECT 1 FROM exercise_progression_graphs pg WHERE pg.created_by = p.id UNION ALL SELECT 1 FROM training_plan_templates pt WHERE pt.created_by = p.id UNION ALL SELECT 1 FROM maturity_models mm WHERE mm.created_by = p.id UNION ALL SELECT 1 FROM media_assets ma WHERE ma.uploaded_by_profile_id = p.id ) ORDER BY total_count DESC, COALESCE(lower(trim(p.email)), ''), p.id """ ) rows = [] for r in cur.fetchall(): d = r2d(r) counts = {k: int(d.pop(f"{k}_count") or 0) for k in _CONTENT_SPECS} d["counts_by_type"] = counts d["total_count"] = int(d.get("total_count") or 0) rows.append(d) return rows @router.get("/items") def list_user_content_items( session: dict = Depends(require_auth), profile_id: Optional[int] = Query(default=None, ge=1), content_type: str = Query(default="all"), visibility: Optional[str] = Query(default="all"), status: Optional[str] = Query(default=None), search: Optional[str] = Query(default=None, max_length=200), limit: int = Query(default=50, ge=1, le=_MAX_ITEMS_LIMIT), offset: int = Query(default=0, ge=0), ): """Paginierte Inhaltsliste — Superadmin sieht auch private Inhalte.""" _require_superadmin(session) ct_raw = (content_type or "all").strip().lower() if ct_raw != "all" and ct_raw not in _CONTENT_SPECS: raise HTTPException(status_code=400, detail="Ungültiger Inhaltstyp") vis_raw = (visibility or "all").strip().lower() if vis_raw not in ("all", *_VALID_VISIBILITY): raise HTTPException(status_code=400, detail="Ungültiger Sichtbarkeits-Filter") types = _types_for_filters(ct_raw if ct_raw != "all" else None, status) if not types: return {"items": [], "total": 0, "limit": limit, "offset": offset} unions: List[str] = [] all_params: List[Any] = [] for tkey in types: spec = _CONTENT_SPECS[tkey] where: List[str] = ["TRUE"] params: List[Any] = [] _append_filters( where, params, spec=spec, profile_id=profile_id, visibility=vis_raw, status=(status or "").strip().lower() or None, search=(search or "").strip() or None, ) unions.append(_build_type_select(spec, tkey) + " WHERE " + " AND ".join(where)) all_params.extend(params) union_sql = " UNION ALL ".join(unions) count_sql = f"SELECT COUNT(*)::int AS c FROM ({union_sql}) sub" list_sql = f""" SELECT sub.*, p.name AS profile_name, p.email AS profile_email, c.name AS club_name FROM ({union_sql}) sub LEFT JOIN profiles p ON p.id = sub.profile_id LEFT JOIN clubs c ON c.id = sub.club_id ORDER BY sub.updated_at DESC NULLS LAST, sub.id DESC LIMIT %s OFFSET %s """ with get_db() as conn: cur = get_cursor(conn) cur.execute(count_sql, tuple(all_params)) count_row = cur.fetchone() total = int(r2d(count_row).get("c") or 0) cur.execute(list_sql, tuple(all_params + [limit, offset])) items = [] for r in cur.fetchall(): d = r2d(r) d["type_label"] = _CONTENT_SPECS[d["content_type"]]["label"] items.append(d) return {"items": items, "total": total, "limit": limit, "offset": offset} @router.patch("/items/{content_type}/{item_id}") def patch_user_content_item( content_type: ContentType, item_id: int, body: UserContentPatchBody, session: dict = Depends(require_auth), ): """Status und/oder Sichtbarkeit setzen (Superadmin).""" _require_superadmin(session) spec = _spec(content_type) table = spec["table"] with get_db() as conn: cur = get_cursor(conn) cur.execute(f"SELECT * FROM {table} WHERE id = %s", (item_id,)) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") current = r2d(row) fields: List[str] = [] params: List[Any] = [] if body.status is not None: st_col = spec.get("status_col") if not st_col: raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keinen Status") st = body.status.strip().lower() if content_type == "exercise" and st not in _VALID_EXERCISE_STATUS: raise HTTPException(status_code=400, detail="Ungültiger Übungs-Status") if content_type == "maturity_model" and st not in _VALID_MATURITY_STATUS: raise HTTPException(status_code=400, detail="Ungültiger Modell-Status") if content_type == "media_asset" and st not in _VALID_MEDIA_RIGHTS: raise HTTPException(status_code=400, detail="Ungültiger Medien-Rechte-Status") fields.append(f"{st_col} = %s") params.append(st) if body.visibility is not None: vis_col = spec.get("visibility_col") if not vis_col: raise HTTPException(status_code=400, detail="Dieser Inhaltstyp hat keine Sichtbarkeit") vis = body.visibility.strip().lower() if vis not in _VALID_VISIBILITY: raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit") if vis == "club" and not current.get(spec.get("club_col") or "club_id"): raise HTTPException( status_code=400, detail="Vereins-Sichtbarkeit erfordert eine Vereinszuordnung (club_id).", ) fields.append(f"{vis_col} = %s") params.append(vis) if body.lifecycle_state is not None: if content_type != "media_asset": raise HTTPException(status_code=400, detail="Lifecycle nur für Medien") lc = body.lifecycle_state.strip().lower() if lc not in _VALID_MEDIA_LIFECYCLE: raise HTTPException(status_code=400, detail="Ungültiger Lifecycle-Status") fields.append("lifecycle_state = %s") params.append(lc) if lc == "active": fields.extend( [ "trash_soft_at = NULL", "trash_hidden_at = NULL", "purge_after_at = NULL", ] ) if not fields: raise HTTPException(status_code=400, detail="Keine gültigen Änderungen") fields.append("updated_at = NOW()") params.append(item_id) cur.execute( f"UPDATE {table} SET {', '.join(fields)} WHERE id = %s RETURNING id", tuple(params), ) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") conn.commit() return {"ok": True, "content_type": content_type, "id": item_id} @router.delete("/items/{content_type}/{item_id}") def delete_user_content_item( content_type: ContentType, item_id: int, session: dict = Depends(require_auth), ): """Inhalt endgültig löschen (Superadmin).""" _require_superadmin(session) spec = _spec(content_type) table = spec["table"] with get_db() as conn: cur = get_cursor(conn) if content_type == "exercise": cur.execute("SELECT id FROM exercises WHERE id = %s", (item_id,)) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Übung nicht gefunden") usage_msg = _exercise_delete_usage_message(cur, item_id) if usage_msg: raise HTTPException(status_code=409, detail=usage_msg) cur.execute("DELETE FROM exercises WHERE id = %s", (item_id,)) conn.commit() return {"ok": True} if content_type == "media_asset": cur.execute("SELECT id FROM media_assets WHERE id = %s", (item_id,)) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Medium nicht gefunden") ok = superadmin_hard_delete_media_asset(cur, conn, item_id) if not ok: raise HTTPException(status_code=404, detail="Medium nicht gefunden") return {"ok": True} cur.execute(f"DELETE FROM {table} WHERE id = %s RETURNING id", (item_id,)) if not cur.fetchone(): raise HTTPException(status_code=404, detail="Inhalt nicht gefunden") conn.commit() return {"ok": True, "content_type": content_type, "id": item_id}