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