shinkan-jinkendo/backend/routers/admin_user_content.py
Lars bd5a409fa7
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
Add Admin User Content Management Features
- 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.
2026-06-06 17:53:25 +02:00

520 lines
18 KiB
Python
Raw 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.

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