Add Admin User Content Management Features
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
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.
This commit is contained in:
parent
3450a9296a
commit
bd5a409fa7
|
|
@ -38,12 +38,13 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| ai_skill_retrieval_admin | `/api/admin/ai-skill-retrieval-profiles*` (CRUD) | Plattform | `require_auth` | nur `superadmin`; JSON `config` | EXEMPT wie `admin_users`; kein Vereinsbezug |
|
||||
| ai_prompts_admin | `/api/admin/ai-prompts*` (Liste, Detail, PUT, Preview, Reset) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; globale `ai_prompts` ohne Mandantenkontext |
|
||||
| exercise_enrichment_admin | `/api/admin/exercise-enrichment/*` (Kandidaten, Preview, Apply) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; plattformweite Übungsliste + Skill-Schreibung; kein TenantContext |
|
||||
| admin_user_content | `/api/admin/user-content/*` (Meta, Nutzer-Summary, Items, PATCH, DELETE) | Plattform | `require_auth` | nur `superadmin` | EXEMPT; Moderation nutzerangelegter Inhalte inkl. privat; kein TenantContext |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-23 — Superadmin `/api/admin/exercise-enrichment/*` (Batch-KI Skills, Status in_review).
|
||||
Letzte Änderung: 2026-06-06 — Superadmin `/api/admin/user-content/*` (Nutzer-Inhalte Moderation).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -193,7 +193,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, admin_user_content, platform_media_storage, media_assets, skills, skill_profiles, training_planning, planning_exercise_suggest, dashboard, training_modules, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, matrix_editor, import_wiki, import_wiki_admin, legal_documents, content_reports, ai_prompts_admin, ai_skill_retrieval_admin, exercise_enrichment_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -203,6 +203,7 @@ app.include_router(clubs.router)
|
|||
app.include_router(club_memberships.router)
|
||||
app.include_router(club_join_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(admin_user_content.router)
|
||||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(media_assets.router)
|
||||
app.include_router(media_assets.admin_rights_router)
|
||||
|
|
|
|||
519
backend/routers/admin_user_content.py
Normal file
519
backend/routers/admin_user_content.py
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
"""
|
||||
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}
|
||||
|
|
@ -26,6 +26,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
"ai_skill_retrieval_admin.py", # Superadmin-Plattform-Konfiguration Skill-KI-Retrieval; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"ai_prompts_admin.py", # Superadmin ai_prompts; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"exercise_enrichment_admin.py", # Superadmin Batch-Übungs-Anreicherung KI; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"admin_user_content.py", # Superadmin Moderation nutzerangelegter Inhalte; require_auth + is_superadmin — kein Vereinsmandant
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
|
|
@ -57,6 +57,7 @@ const AdminLegalDocumentsPage = lazy(() => import('./pages/AdminLegalDocumentsPa
|
|||
const AdminAiSkillRetrievalPage = lazy(() => import('./pages/AdminAiSkillRetrievalPage'))
|
||||
const AdminAiPromptsPage = lazy(() => import('./pages/AdminAiPromptsPage'))
|
||||
const AdminExerciseEnrichmentPage = lazy(() => import('./pages/AdminExerciseEnrichmentPage'))
|
||||
const AdminUserContentPage = lazy(() => import('./pages/AdminUserContentPage'))
|
||||
const SettingsLegalPage = lazy(() => import('./pages/SettingsLegalPage'))
|
||||
|
||||
/** Shield „Admin“: nur Super-Admin (global). Vereinsorga: Vereine → Mitglieder. */
|
||||
|
|
@ -327,6 +328,14 @@ const appRouter = createBrowserRouter([
|
|||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: 'admin/user-content',
|
||||
element: (
|
||||
<PlatformAdminRoute>
|
||||
<AdminUserContentPage />
|
||||
</PlatformAdminRoute>
|
||||
),
|
||||
},
|
||||
{ path: 'trainer-contexts', element: <TrainerContextsPage /> },
|
||||
],
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Brain, Sparkles, Wand2 } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale, Sparkles, Wand2, Activity } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal) — nur für Super-Admins (globaler Portal-Mandant).
|
||||
|
|
@ -8,6 +8,7 @@ export default function AdminPageNav() {
|
|||
const pages = [
|
||||
{ to: '/admin/hierarchy', label: 'Hierarchie', icon: TreePine },
|
||||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
{ to: '/admin/user-content', label: 'Nutzer-Inhalte', icon: Activity },
|
||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
|
||||
|
|
|
|||
681
frontend/src/pages/AdminUserContentPage.jsx
Normal file
681
frontend/src/pages/AdminUserContentPage.jsx
Normal file
|
|
@ -0,0 +1,681 @@
|
|||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link, Navigate } from 'react-router-dom'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
|
||||
const VISIBILITY_OPTIONS = [
|
||||
{ value: 'all', label: 'Alle Sichtbarkeiten' },
|
||||
{ value: 'private', label: 'Privat' },
|
||||
{ value: 'club', label: 'Verein' },
|
||||
{ value: 'official', label: 'Offiziell' },
|
||||
]
|
||||
|
||||
const VISIBILITY_LABEL = {
|
||||
private: 'Privat',
|
||||
club: 'Verein',
|
||||
official: 'Offiziell',
|
||||
}
|
||||
|
||||
const STATUS_LABELS = {
|
||||
draft: 'Entwurf',
|
||||
in_review: 'In Prüfung',
|
||||
approved: 'Freigegeben',
|
||||
archived: 'Archiviert',
|
||||
active: 'Aktiv',
|
||||
legacy_unreviewed: 'Rechte ungeprüft',
|
||||
declared: 'Rechte erklärt',
|
||||
blocked: 'Gesperrt',
|
||||
}
|
||||
|
||||
const LIFECYCLE_LABELS = {
|
||||
active: 'Aktiv',
|
||||
trash_soft: 'Papierkorb (soft)',
|
||||
trash_hidden: 'Papierkorb (hidden)',
|
||||
}
|
||||
|
||||
function formatDate(value) {
|
||||
if (!value) return '—'
|
||||
try {
|
||||
return new Date(value).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
} catch {
|
||||
return String(value)
|
||||
}
|
||||
}
|
||||
|
||||
function contentLink(item) {
|
||||
const id = item.id
|
||||
switch (item.content_type) {
|
||||
case 'exercise':
|
||||
return `/exercises/${id}`
|
||||
case 'training_module':
|
||||
return `/planning/training-modules/${id}`
|
||||
case 'framework_program':
|
||||
return `/planning/framework-programs/${id}`
|
||||
case 'plan_template':
|
||||
return `/planning/plan-templates/${id}`
|
||||
case 'maturity_model':
|
||||
return '/admin/maturity-models'
|
||||
case 'media_asset':
|
||||
return '/media'
|
||||
default:
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function statusOptionsForType(meta, contentType) {
|
||||
const t = meta?.content_types?.find((x) => x.id === contentType)
|
||||
return (t?.status_values || []).map((v) => ({
|
||||
value: v,
|
||||
label: STATUS_LABELS[v] || v,
|
||||
}))
|
||||
}
|
||||
|
||||
function EditModal({ open, item, meta, onClose, onSaved }) {
|
||||
const [status, setStatus] = useState('')
|
||||
const [visibility, setVisibility] = useState('')
|
||||
const [lifecycle, setLifecycle] = useState('')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
useEffect(() => {
|
||||
if (!item) return
|
||||
setStatus(item.status || '')
|
||||
setVisibility(item.visibility || '')
|
||||
setLifecycle(item.extra_status || 'active')
|
||||
setError('')
|
||||
}, [item])
|
||||
|
||||
if (!open || !item) return null
|
||||
|
||||
const typeMeta = meta?.content_types?.find((x) => x.id === item.content_type)
|
||||
const statusOpts = statusOptionsForType(meta, item.content_type)
|
||||
|
||||
const submit = async () => {
|
||||
setSaving(true)
|
||||
setError('')
|
||||
try {
|
||||
const body = {}
|
||||
if (typeMeta?.has_status && status && status !== item.status) body.status = status
|
||||
if (typeMeta?.has_visibility && visibility && visibility !== item.visibility) {
|
||||
body.visibility = visibility
|
||||
}
|
||||
if (item.content_type === 'media_asset' && lifecycle && lifecycle !== item.extra_status) {
|
||||
body.lifecycle_state = lifecycle
|
||||
}
|
||||
if (!Object.keys(body).length) {
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
await api.patchAdminUserContentItem(item.content_type, item.id, body)
|
||||
await onSaved()
|
||||
onClose()
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setSaving(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="modal-overlay" role="dialog" aria-modal="true">
|
||||
<div className="card modal-card" style={{ maxWidth: 480, width: '100%' }}>
|
||||
<h2 style={{ marginTop: 0 }}>Inhalt bearbeiten</h2>
|
||||
<p className="text-muted" style={{ marginTop: 0 }}>
|
||||
{item.type_label} · #{item.id}
|
||||
</p>
|
||||
<p style={{ fontWeight: 600 }}>{item.title || '—'}</p>
|
||||
|
||||
{typeMeta?.has_status ? (
|
||||
<div className="form-row" style={{ marginTop: 16 }}>
|
||||
<label className="form-label" htmlFor="uc-status">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="uc-status"
|
||||
className="form-input"
|
||||
value={status}
|
||||
onChange={(e) => setStatus(e.target.value)}
|
||||
>
|
||||
{statusOpts.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{typeMeta?.has_visibility ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="uc-vis">
|
||||
Sichtbarkeit
|
||||
</label>
|
||||
<select
|
||||
id="uc-vis"
|
||||
className="form-input"
|
||||
value={visibility}
|
||||
onChange={(e) => setVisibility(e.target.value)}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.filter((o) => o.value !== 'all').map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{item.content_type === 'media_asset' ? (
|
||||
<div className="form-row">
|
||||
<label className="form-label" htmlFor="uc-lc">
|
||||
Lifecycle
|
||||
</label>
|
||||
<select
|
||||
id="uc-lc"
|
||||
className="form-input"
|
||||
value={lifecycle}
|
||||
onChange={(e) => setLifecycle(e.target.value)}
|
||||
>
|
||||
{Object.entries(LIFECYCLE_LABELS).map(([v, label]) => (
|
||||
<option key={v} value={v}>
|
||||
{label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error ? (
|
||||
<p style={{ color: 'var(--danger)', marginTop: 12 }} role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div style={{ display: 'flex', gap: 8, marginTop: 20, justifyContent: 'flex-end' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="button" className="btn btn-primary" onClick={submit} disabled={saving}>
|
||||
{saving ? 'Speichern…' : 'Speichern'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function AdminUserContentPage() {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [meta, setMeta] = useState(null)
|
||||
const [userSummary, setUserSummary] = useState([])
|
||||
const [items, setItems] = useState([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [itemsLoading, setItemsLoading] = useState(false)
|
||||
const [error, setError] = useState('')
|
||||
|
||||
const [profileId, setProfileId] = useState('')
|
||||
const [contentType, setContentType] = useState('all')
|
||||
const [visibility, setVisibility] = useState('all')
|
||||
const [status, setStatus] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [offset, setOffset] = useState(0)
|
||||
const limit = 50
|
||||
|
||||
const [editItem, setEditItem] = useState(null)
|
||||
|
||||
const contentTypeOptions = useMemo(() => {
|
||||
const base = [{ value: 'all', label: 'Alle Typen' }]
|
||||
for (const t of meta?.content_types || []) {
|
||||
base.push({ value: t.id, label: t.label })
|
||||
}
|
||||
return base
|
||||
}, [meta])
|
||||
|
||||
const statusFilterOptions = useMemo(() => {
|
||||
if (contentType === 'all') {
|
||||
return [
|
||||
{ value: '', label: 'Beliebiger Status' },
|
||||
{ value: 'draft', label: STATUS_LABELS.draft },
|
||||
{ value: 'in_review', label: STATUS_LABELS.in_review },
|
||||
{ value: 'approved', label: STATUS_LABELS.approved },
|
||||
{ value: 'archived', label: STATUS_LABELS.archived },
|
||||
{ value: 'active', label: STATUS_LABELS.active },
|
||||
{ value: 'legacy_unreviewed', label: STATUS_LABELS.legacy_unreviewed },
|
||||
]
|
||||
}
|
||||
return [
|
||||
{ value: '', label: 'Beliebiger Status' },
|
||||
...statusOptionsForType(meta, contentType),
|
||||
]
|
||||
}, [contentType, meta])
|
||||
|
||||
const loadSummary = useCallback(async () => {
|
||||
const [m, s] = await Promise.all([api.getAdminUserContentMeta(), api.listAdminUserContentSummary()])
|
||||
setMeta(m)
|
||||
setUserSummary(Array.isArray(s) ? s : [])
|
||||
}, [])
|
||||
|
||||
const loadItems = useCallback(async (forcedOffset) => {
|
||||
setItemsLoading(true)
|
||||
try {
|
||||
const params = {
|
||||
content_type: contentType,
|
||||
visibility,
|
||||
limit,
|
||||
offset: forcedOffset ?? offset,
|
||||
}
|
||||
if (profileId) params.profile_id = Number(profileId)
|
||||
if (status) params.status = status
|
||||
if (search.trim()) params.search = search.trim()
|
||||
const res = await api.listAdminUserContentItems(params)
|
||||
setItems(Array.isArray(res?.items) ? res.items : [])
|
||||
setTotal(Number(res?.total) || 0)
|
||||
} finally {
|
||||
setItemsLoading(false)
|
||||
}
|
||||
}, [contentType, visibility, status, search, profileId, offset])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
await loadSummary()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setLoading(false)
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, loadSummary])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSuperadmin) return
|
||||
let cancelled = false
|
||||
;(async () => {
|
||||
try {
|
||||
await loadItems()
|
||||
} catch (e) {
|
||||
if (!cancelled) setError(e.message || String(e))
|
||||
}
|
||||
})()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [isSuperadmin, loadItems])
|
||||
|
||||
const applyFilters = () => {
|
||||
setOffset(0)
|
||||
loadItems(0)
|
||||
}
|
||||
|
||||
const handleDelete = async (item) => {
|
||||
const label = item.title || `${item.type_label} #${item.id}`
|
||||
if (
|
||||
!confirm(
|
||||
`„${label}" wirklich endgültig löschen?\n\nDiese Aktion kann nicht rückgängig gemacht werden.`,
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteAdminUserContentItem(item.content_type, item.id)
|
||||
await Promise.all([loadItems(), loadSummary()])
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
if (!isSuperadmin) return <Navigate to="/" replace />
|
||||
|
||||
return (
|
||||
<div className="page" style={{ paddingBottom: 96 }}>
|
||||
<AdminPageNav />
|
||||
<header style={{ marginBottom: 20 }}>
|
||||
<h1 style={{ margin: '0 0 8px' }}>Nutzer-Inhalte</h1>
|
||||
<p className="text-muted" style={{ margin: 0 }}>
|
||||
Aktivitäten aller Nutzer einsehen — inklusive privater Inhalte. Status setzen oder Inhalte
|
||||
löschen (nur Superadmin).
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{error ? (
|
||||
<div className="card" style={{ borderColor: 'var(--danger)', marginBottom: 16 }}>
|
||||
<p style={{ margin: 0, color: 'var(--danger)' }}>{error}</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{loading ? (
|
||||
<div className="spinner" aria-label="Laden" />
|
||||
) : (
|
||||
<>
|
||||
<section className="card" style={{ marginBottom: 16 }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Aktivität je Nutzer</h2>
|
||||
{userSummary.length === 0 ? (
|
||||
<p className="text-muted" style={{ margin: 0 }}>
|
||||
Noch keine nutzerangelegten Inhalte.
|
||||
</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="data-table" style={{ width: '100%', minWidth: 640 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Nutzer</th>
|
||||
<th>Gesamt</th>
|
||||
{(meta?.content_types || []).map((t) => (
|
||||
<th key={t.id}>{t.label}</th>
|
||||
))}
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{userSummary.map((u) => (
|
||||
<tr key={u.id}>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600 }}>{u.name || `Profil #${u.id}`}</div>
|
||||
<div className="text-muted" style={{ fontSize: '0.85rem' }}>
|
||||
{u.email || '—'}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<strong>{u.total_count}</strong>
|
||||
</td>
|
||||
{(meta?.content_types || []).map((t) => (
|
||||
<td key={t.id}>{u.counts_by_type?.[t.id] ?? 0}</td>
|
||||
))}
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.85rem', padding: '4px 10px' }}
|
||||
onClick={() => {
|
||||
setProfileId(String(u.id))
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
Filtern
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="card">
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.05rem' }}>Inhalte</h2>
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'grid',
|
||||
gridTemplateColumns: 'repeat(auto-fill, minmax(160px, 1fr))',
|
||||
gap: 12,
|
||||
marginBottom: 16,
|
||||
}}
|
||||
>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="uc-user">
|
||||
Nutzer
|
||||
</label>
|
||||
<select
|
||||
id="uc-user"
|
||||
className="form-input"
|
||||
value={profileId}
|
||||
onChange={(e) => {
|
||||
setProfileId(e.target.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
<option value="">Alle Nutzer</option>
|
||||
{userSummary.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name || u.email || `#${u.id}`}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="uc-type">
|
||||
Typ
|
||||
</label>
|
||||
<select
|
||||
id="uc-type"
|
||||
className="form-input"
|
||||
value={contentType}
|
||||
onChange={(e) => {
|
||||
setContentType(e.target.value)
|
||||
setStatus('')
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{contentTypeOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="uc-vis-filter">
|
||||
Sichtbarkeit
|
||||
</label>
|
||||
<select
|
||||
id="uc-vis-filter"
|
||||
className="form-input"
|
||||
value={visibility}
|
||||
onChange={(e) => {
|
||||
setVisibility(e.target.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{VISIBILITY_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="form-label" htmlFor="uc-status-filter">
|
||||
Status
|
||||
</label>
|
||||
<select
|
||||
id="uc-status-filter"
|
||||
className="form-input"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setStatus(e.target.value)
|
||||
setOffset(0)
|
||||
}}
|
||||
>
|
||||
{statusFilterOptions.map((o) => (
|
||||
<option key={o.value || '_any'} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div style={{ gridColumn: 'span 2' }}>
|
||||
<label className="form-label" htmlFor="uc-search">
|
||||
Suche (Titel)
|
||||
</label>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<input
|
||||
id="uc-search"
|
||||
className="form-input"
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
onKeyDown={(e) => e.key === 'Enter' && applyFilters()}
|
||||
placeholder="Titel oder Dateiname…"
|
||||
/>
|
||||
<button type="button" className="btn btn-primary" onClick={applyFilters}>
|
||||
Suchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{itemsLoading ? (
|
||||
<div className="spinner" aria-label="Inhalte laden" />
|
||||
) : items.length === 0 ? (
|
||||
<p className="text-muted">Keine Inhalte für die aktuellen Filter.</p>
|
||||
) : (
|
||||
<div style={{ overflowX: 'auto' }}>
|
||||
<table className="data-table" style={{ width: '100%', minWidth: 900 }}>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Typ</th>
|
||||
<th>Titel</th>
|
||||
<th>Nutzer</th>
|
||||
<th>Sichtbarkeit</th>
|
||||
<th>Status</th>
|
||||
<th>Aktualisiert</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item) => {
|
||||
const href = contentLink(item)
|
||||
return (
|
||||
<tr key={`${item.content_type}-${item.id}`}>
|
||||
<td>{item.type_label}</td>
|
||||
<td>
|
||||
<div style={{ fontWeight: 600, maxWidth: 280 }}>
|
||||
{href ? (
|
||||
<Link to={href}>{item.title || '—'}</Link>
|
||||
) : (
|
||||
item.title || '—'
|
||||
)}
|
||||
</div>
|
||||
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
#{item.id}
|
||||
{item.club_name ? ` · ${item.club_name}` : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
<div>{item.profile_name || '—'}</div>
|
||||
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
{item.profile_email || (item.profile_id ? `#${item.profile_id}` : '—')}
|
||||
</div>
|
||||
</td>
|
||||
<td>
|
||||
{item.visibility ? (
|
||||
<span
|
||||
style={{
|
||||
color:
|
||||
item.visibility === 'private' ? 'var(--text2)' : 'var(--accent)',
|
||||
}}
|
||||
>
|
||||
{VISIBILITY_LABEL[item.visibility] || item.visibility}
|
||||
</span>
|
||||
) : (
|
||||
'—'
|
||||
)}
|
||||
</td>
|
||||
<td>
|
||||
{item.status ? STATUS_LABELS[item.status] || item.status : '—'}
|
||||
{item.extra_status && item.extra_status !== 'active' ? (
|
||||
<div className="text-muted" style={{ fontSize: '0.8rem' }}>
|
||||
{LIFECYCLE_LABELS[item.extra_status] || item.extra_status}
|
||||
</div>
|
||||
) : null}
|
||||
</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(item.updated_at)}</td>
|
||||
<td>
|
||||
<div style={{ display: 'flex', gap: 6, flexWrap: 'wrap' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '0.8rem', padding: '4px 8px' }}
|
||||
onClick={() => setEditItem(item)}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
fontSize: '0.8rem',
|
||||
padding: '4px 8px',
|
||||
color: 'var(--danger)',
|
||||
}}
|
||||
onClick={() => handleDelete(item)}
|
||||
>
|
||||
Löschen
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{total > limit ? (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginTop: 16,
|
||||
}}
|
||||
>
|
||||
<span className="text-muted" style={{ fontSize: '0.9rem' }}>
|
||||
{total} Einträge · Seite {Math.floor(offset / limit) + 1} von{' '}
|
||||
{Math.ceil(total / limit)}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset <= 0}
|
||||
onClick={() => setOffset(Math.max(0, offset - limit))}
|
||||
>
|
||||
Zurück
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={offset + limit >= total}
|
||||
onClick={() => setOffset(offset + limit)}
|
||||
>
|
||||
Weiter
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
</>
|
||||
)}
|
||||
|
||||
<EditModal
|
||||
open={!!editItem}
|
||||
item={editItem}
|
||||
meta={meta}
|
||||
onClose={() => setEditItem(null)}
|
||||
onSaved={async () => {
|
||||
await Promise.all([loadItems(), loadSummary()])
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -50,6 +50,39 @@ export async function listAdminUsers() {
|
|||
return request('/api/admin/users')
|
||||
}
|
||||
|
||||
/** Superadmin: Metadaten zu nutzerangelegten Inhaltstypen. */
|
||||
export async function getAdminUserContentMeta() {
|
||||
return request('/api/admin/user-content/meta')
|
||||
}
|
||||
|
||||
/** Superadmin: Aktivitätsübersicht je Nutzer (Anzahl Inhalte). */
|
||||
export async function listAdminUserContentSummary() {
|
||||
return request('/api/admin/user-content/users-summary')
|
||||
}
|
||||
|
||||
/** Superadmin: Inhalte aller Nutzer (inkl. privat) — filterbar. */
|
||||
export async function listAdminUserContentItems(params = {}) {
|
||||
const qs = new URLSearchParams()
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v !== undefined && v !== null && v !== '') qs.set(k, String(v))
|
||||
}
|
||||
const q = qs.toString()
|
||||
return request(`/api/admin/user-content/items${q ? `?${q}` : ''}`)
|
||||
}
|
||||
|
||||
/** Superadmin: Status/Sichtbarkeit eines Inhalts setzen. */
|
||||
export async function patchAdminUserContentItem(contentType, itemId, body) {
|
||||
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
/** Superadmin: Inhalt löschen. */
|
||||
export async function deleteAdminUserContentItem(contentType, itemId) {
|
||||
return request(`/api/admin/user-content/items/${contentType}/${itemId}`, { method: 'DELETE' })
|
||||
}
|
||||
|
||||
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
|
||||
export async function getPlatformMediaStorage() {
|
||||
return request('/api/admin/platform-media-storage')
|
||||
|
|
@ -819,6 +852,11 @@ export const api = {
|
|||
getCurrentProfile,
|
||||
listProfiles,
|
||||
listAdminUsers,
|
||||
getAdminUserContentMeta,
|
||||
listAdminUserContentSummary,
|
||||
listAdminUserContentItems,
|
||||
patchAdminUserContentItem,
|
||||
deleteAdminUserContentItem,
|
||||
updateProfile,
|
||||
managementPasswordReset,
|
||||
changePassword,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user