shinkan-jinkendo/backend/routers/admin_user_content.py

520 lines
18 KiB
Python
Raw Normal View History

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