All checks were successful
Deploy Development / deploy (push) Successful in 42s
Test Suite / pytest-backend (push) Successful in 44s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 15s
Test Suite / k6 /health Baseline (push) Successful in 35s
Test Suite / playwright-tests (push) Successful in 1m18s
Test Suite / pytest-backend (pull_request) Successful in 38s
Test Suite / lint-backend (pull_request) Successful in 0s
Test Suite / build-frontend (pull_request) Successful in 13s
Test Suite / k6 /health Baseline (pull_request) Successful in 38s
Test Suite / playwright-tests (pull_request) Successful in 1m12s
- Introduced a new admin user content management endpoint for superadmins, allowing for moderation of user-generated content. - Updated the backend to include new API functions for retrieving, patching, and deleting user content items. - Enhanced the frontend with a new Admin User Content page and navigation link for easy access to user content management. - Updated access layer documentation to reflect the new endpoint and its exempt status. - Incremented version to 0.8.191 and updated changelog to document these additions in admin functionality.
520 lines
18 KiB
Python
520 lines
18 KiB
Python
"""
|
||
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}
|