diff --git a/backend/media_rights.py b/backend/media_rights.py
index 426186d..b9230c3 100644
--- a/backend/media_rights.py
+++ b/backend/media_rights.py
@@ -330,7 +330,7 @@ def write_rights_declaration(
def write_audit_log_entry(
cur: Any,
asset_id: int,
- acting_profile_id: int,
+ acting_profile_id: Optional[int],
event_type: str,
old_values: dict,
new_values: dict,
diff --git a/backend/migrations/053_content_report_audit_event.sql b/backend/migrations/053_content_report_audit_event.sql
new file mode 100644
index 0000000..82b4ed4
--- /dev/null
+++ b/backend/migrations/053_content_report_audit_event.sql
@@ -0,0 +1,15 @@
+-- Migration 053: 'content_report_filed' als neuer event_type im Medien-Audit-Log
+-- Notwendig fuer P-13: Journaleintrag beim Einreichen einer Inhaltsmeldung.
+
+ALTER TABLE media_asset_audit_log
+ DROP CONSTRAINT IF EXISTS media_asset_audit_log_event_type_check;
+
+ALTER TABLE media_asset_audit_log
+ ADD CONSTRAINT media_asset_audit_log_event_type_check
+ CHECK (event_type IN (
+ 'visibility_change',
+ 'copyright_change',
+ 'metadata_change',
+ 'lifecycle_change',
+ 'content_report_filed'
+ ));
diff --git a/backend/routers/content_reports.py b/backend/routers/content_reports.py
index c4c2c4f..d00b3d3 100644
--- a/backend/routers/content_reports.py
+++ b/backend/routers/content_reports.py
@@ -11,12 +11,20 @@ Architektur:
Berechtigungen:
- Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt)
-- Liste/Detail: Plattform-Admin (admin/superadmin)
+- Liste/Detail: Plattform-Admin (alle Meldungen) oder Club-Admin (nur eigene Vereinsmedien)
- Status-Bearbeitung: Plattform-Admin
- Legal Hold aus Meldung: ausschliesslich Superadmin
+
+E-Mail-Benachrichtigungen:
+- Nach Eingang: Bestaetigung an Melder
+- Nach Eingang: Benachrichtigung aller Plattform-Admins (best-effort, kein Fehler wenn SMTP fehlt)
+
+Audit-Log (Migration 053):
+- Bei media_asset-Meldungen: Eintrag in media_asset_audit_log (event_type='content_report_filed')
"""
from __future__ import annotations
+import os
import re
from typing import Optional
@@ -30,6 +38,7 @@ from media_legal_hold import (
assert_superadmin_for_legal_hold,
set_legal_hold,
)
+from media_rights import write_audit_log_entry
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["content_reports"])
@@ -46,6 +55,17 @@ REPORT_REASONS = frozenset({
"other",
})
+REASON_LABELS_DE = {
+ "copyright": "Urheberrecht",
+ "image_rights": "Bildrechte",
+ "privacy": "Datenschutz / Persönlichkeitsrecht",
+ "minors": "Minderjährige",
+ "illegal_content": "Rechtswidriger Inhalt",
+ "youth_protection": "Jugendschutz",
+ "offensive_content": "Beleidigender Inhalt",
+ "other": "Sonstiges",
+}
+
# Gruende mit hoher Prioritaet (illegal, Minderjaehrigenschutz)
HIGH_PRIORITY_REASONS = frozenset({
"minors",
@@ -157,7 +177,6 @@ def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, glob
if is_platform_admin(global_role):
cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,))
return cur.fetchone() is not None
- # Sichtbarkeit pruefen: official (immer), club (Mitglied), private (Eigentuemerrechte sind breiter)
cur.execute(
"""
SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id
@@ -188,7 +207,6 @@ def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, glob
)
return cur.fetchone() is not None
if visibility == "private":
- # Private Medien: Eigentuemerrechte oder gleicher Verein mit club_admin
if asset.get("uploaded_by_profile_id") == profile_id:
return True
club_id = asset.get("club_id")
@@ -265,6 +283,96 @@ def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[i
return d.get("profile_id"), d.get("role")
+def _get_platform_admin_emails(cur) -> list[str]:
+ """Gibt E-Mail-Adressen aller aktiven Plattform-Admins zurueck."""
+ cur.execute(
+ """
+ SELECT email FROM profiles
+ WHERE role IN ('admin', 'superadmin')
+ AND email IS NOT NULL AND email <> ''
+ """
+ )
+ return [r2d(row)["email"] for row in cur.fetchall()]
+
+
+def _send_email(to: str, subject: str, body: str) -> None:
+ """SMTP-Versand (best-effort, ignoriert Fehler und nicht konfiguriertes SMTP)."""
+ try:
+ import smtplib, ssl
+ from email.mime.text import MIMEText
+
+ smtp_host = (os.getenv("SMTP_HOST") or "").strip()
+ smtp_user = (os.getenv("SMTP_USER") or "").strip()
+ smtp_pass = os.getenv("SMTP_PASS") or ""
+ if not smtp_host or not smtp_user or not smtp_pass:
+ return
+ smtp_port = int(os.getenv("SMTP_PORT") or "587")
+ smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
+ force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
+ use_tls = os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no")
+
+ msg = MIMEText(body, "plain", "utf-8")
+ msg["Subject"] = subject
+ msg["From"] = smtp_from
+ msg["To"] = to
+ ctx = ssl.create_default_context()
+ if force_ssl:
+ with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as srv:
+ srv.login(smtp_user, smtp_pass)
+ srv.send_message(msg)
+ else:
+ with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as srv:
+ srv.ehlo()
+ if use_tls:
+ srv.starttls(context=ctx)
+ srv.ehlo()
+ srv.login(smtp_user, smtp_pass)
+ srv.send_message(msg)
+ except Exception as exc:
+ print(f"[SMTP content_reports] Fehler: {exc}")
+
+
+def _notify_report_submitted(
+ *,
+ report_id: int,
+ reporter_name: str,
+ reporter_email: str,
+ reason: str,
+ priority: str,
+ target_type: str,
+ target_id: int,
+ admin_emails: list[str],
+) -> None:
+ """Sendet Bestaetigungs-Mail an Melder + Benachrichtigung an alle Plattform-Admins (best-effort)."""
+ app_url = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
+ reason_label = REASON_LABELS_DE.get(reason, reason)
+ target_label = ("Medium" if target_type == "media_asset" else "Übung") + f" #{target_id}"
+
+ # Bestaetigungs-Mail an Melder
+ confirmation_body = (
+ f"Hallo {reporter_name},\n\n"
+ f"Ihre Meldung (#{report_id}) wurde entgegengenommen.\n\n"
+ f"Meldegrund: {reason_label}\n"
+ f"Gemeldeter Inhalt: {target_label}\n\n"
+ f"Ein Administrator wird Ihre Meldung zeitnah prüfen.\n\n"
+ f"Shinkan Jinkendo"
+ )
+ _send_email(reporter_email, f"Meldung #{report_id} eingegangen – Shinkan Jinkendo", confirmation_body)
+
+ # Admin-Benachrichtigung
+ prio_label = "DRINGEND" if priority == "high" else "normal"
+ admin_body = (
+ f"Neue Inhaltsmeldung #{report_id} [{prio_label}]\n\n"
+ f"Meldegrund: {reason_label}\n"
+ f"Ziel: {target_label}\n"
+ f"Gemeldet von: {reporter_name} <{reporter_email}>\n\n"
+ f"Zur Inbox: {app_url}/inbox\n"
+ )
+ subject = f"Inhaltsmeldung #{report_id} [{prio_label}] – Shinkan Jinkendo"
+ for email in admin_emails:
+ _send_email(email, subject, admin_body)
+
+
def _report_row_to_dict(row) -> dict:
return r2d(row)
@@ -282,6 +390,8 @@ def submit_content_report(
- Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte)
- Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf
- Gutglaubenserklärung (good_faith_confirmed=true) ist Pflicht
+ - Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
+ - E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
"""
if not body.good_faith_confirmed:
raise HTTPException(
@@ -291,6 +401,9 @@ def submit_content_report(
priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal"
+ report_id: int
+ admin_emails: list[str] = []
+
with get_db() as conn:
cur = get_cursor(conn)
@@ -341,10 +454,43 @@ def submit_content_report(
)
row = cur.fetchone()
result = r2d(row)
+ report_id = int(result["id"])
+
+ # Audit-Log-Eintrag (nur fuer media_asset-Meldungen)
+ if body.target_type == "media_asset":
+ write_audit_log_entry(
+ cur,
+ body.target_id,
+ profile_id,
+ "content_report_filed",
+ {},
+ {
+ "content_report_id": report_id,
+ "report_reason": body.report_reason,
+ "priority": priority,
+ "reporter_email": body.reporter_email,
+ },
+ )
+
+ # Admin-E-Mails vor Commit abfragen (Verbindung noch offen)
+ admin_emails = _get_platform_admin_emails(cur)
+
conn.commit()
+ # E-Mails nach Commit senden (best-effort, kein Rollback-Risiko)
+ _notify_report_submitted(
+ report_id=report_id,
+ reporter_name=body.reporter_name,
+ reporter_email=body.reporter_email,
+ reason=body.report_reason,
+ priority=priority,
+ target_type=body.target_type,
+ target_id=body.target_id,
+ admin_emails=admin_emails,
+ )
+
return {
- "id": result["id"],
+ "id": report_id,
"status": result["status"],
"priority": result["priority"],
"submitted_at": str(result["submitted_at"]),
@@ -357,59 +503,127 @@ def list_inbox_content_reports(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
- Alle Meldungen fuer die Admin-Inbox.
- Sichtbar fuer Plattform-Admins (admin + superadmin).
- In der InboxPage neben den Join-Requests angezeigt.
+ Meldungen fuer die Inbox.
+ - Plattform-Admins (admin/superadmin): alle Meldungen
+ - Club-Admins: nur Meldungen ueber Medien ihres Vereins
"""
- _assert_platform_admin(tenant.global_role)
+ pid = tenant.profile_id
+ role = tenant.global_role
+
+ is_padmin = is_platform_admin(role)
with get_db() as conn:
cur = get_cursor(conn)
- cur.execute(
- """
- SELECT
- cr.id,
- cr.target_type,
- cr.target_id,
- cr.report_reason,
- cr.report_description,
- cr.reporter_name,
- cr.reporter_email,
- cr.reporter_profile_id,
- cr.priority,
- cr.status,
- cr.assigned_to_profile_id,
- cr.reviewed_by_profile_id,
- cr.reviewed_at,
- cr.resolution_note,
- cr.submitted_at,
- cr.created_at,
- cr.updated_at,
- -- Ziel-Medium-Infos (wenn target_type = media_asset)
- ma.original_filename AS target_filename,
- ma.visibility AS target_visibility,
- ma.media_kind AS target_media_kind,
- ma.legal_hold_active AS target_legal_hold_active,
- -- Ziel-Uebungs-Infos (wenn target_type = exercise)
- ex.name AS target_exercise_name,
- -- Reviewer-Name
- rev.name AS reviewed_by_name,
- -- Zugewiesen-Name
- asgn.name AS assigned_to_name
- FROM content_reports cr
- LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
- LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
- LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
- LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
- ORDER BY
- CASE WHEN cr.status = 'submitted' THEN 0
- WHEN cr.status = 'under_review' THEN 1
- ELSE 2 END ASC,
- cr.priority DESC,
- cr.created_at ASC
- LIMIT 500
- """,
- )
+
+ if is_padmin:
+ # Plattform-Admin: alle Meldungen
+ cur.execute(
+ """
+ SELECT
+ cr.id,
+ cr.target_type,
+ cr.target_id,
+ cr.report_reason,
+ cr.report_description,
+ cr.reporter_name,
+ cr.reporter_email,
+ cr.reporter_profile_id,
+ cr.priority,
+ cr.status,
+ cr.assigned_to_profile_id,
+ cr.reviewed_by_profile_id,
+ cr.reviewed_at,
+ cr.resolution_note,
+ cr.submitted_at,
+ cr.created_at,
+ cr.updated_at,
+ ma.original_filename AS target_filename,
+ ma.visibility AS target_visibility,
+ ma.media_kind AS target_media_kind,
+ ma.legal_hold_active AS target_legal_hold_active,
+ ex.name AS target_exercise_name,
+ rev.name AS reviewed_by_name,
+ asgn.name AS assigned_to_name
+ FROM content_reports cr
+ LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
+ LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
+ LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
+ LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
+ ORDER BY
+ CASE WHEN cr.status = 'submitted' THEN 0
+ WHEN cr.status = 'under_review' THEN 1
+ ELSE 2 END ASC,
+ cr.priority DESC,
+ cr.created_at ASC
+ LIMIT 500
+ """,
+ )
+ else:
+ # Club-Admin: Meldungen ueber Medien der eigenen Vereine
+ # Zuerst pruefen ob ueberhaupt Club-Admin-Rolle vorhanden
+ cur.execute(
+ """
+ SELECT COUNT(*) AS cnt
+ FROM club_members cm
+ INNER JOIN club_member_roles r ON r.club_member_id = cm.id
+ WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin'
+ """,
+ (pid,),
+ )
+ cnt_row = r2d(cur.fetchone())
+ if int(cnt_row.get("cnt", 0)) == 0:
+ raise HTTPException(status_code=403, detail="Kein Zugriff auf Inhaltsmeldungen")
+
+ cur.execute(
+ """
+ SELECT
+ cr.id,
+ cr.target_type,
+ cr.target_id,
+ cr.report_reason,
+ cr.report_description,
+ cr.reporter_name,
+ cr.reporter_email,
+ cr.reporter_profile_id,
+ cr.priority,
+ cr.status,
+ cr.assigned_to_profile_id,
+ cr.reviewed_by_profile_id,
+ cr.reviewed_at,
+ cr.resolution_note,
+ cr.submitted_at,
+ cr.created_at,
+ cr.updated_at,
+ ma.original_filename AS target_filename,
+ ma.visibility AS target_visibility,
+ ma.media_kind AS target_media_kind,
+ ma.legal_hold_active AS target_legal_hold_active,
+ ex.name AS target_exercise_name,
+ rev.name AS reviewed_by_name,
+ asgn.name AS assigned_to_name
+ FROM content_reports cr
+ LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
+ LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
+ LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
+ LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
+ WHERE cr.target_type = 'media_asset'
+ AND ma.club_id IN (
+ SELECT cm.club_id
+ FROM club_members cm
+ INNER JOIN club_member_roles r ON r.club_member_id = cm.id
+ WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin'
+ )
+ ORDER BY
+ CASE WHEN cr.status = 'submitted' THEN 0
+ WHEN cr.status = 'under_review' THEN 1
+ ELSE 2 END ASC,
+ cr.priority DESC,
+ cr.created_at ASC
+ LIMIT 200
+ """,
+ (pid,),
+ )
+
return [_report_row_to_dict(row) for row in cur.fetchall()]
@@ -427,22 +641,22 @@ def get_content_report(
"""
SELECT
cr.*,
- ma.original_filename AS target_filename,
- ma.visibility AS target_visibility,
- ma.media_kind AS target_media_kind,
- ma.legal_hold_active AS target_legal_hold_active,
+ ma.original_filename AS target_filename,
+ ma.visibility AS target_visibility,
+ ma.media_kind AS target_media_kind,
+ ma.legal_hold_active AS target_legal_hold_active,
ma.legal_hold_reason_code AS target_legal_hold_reason_code,
- ex.name AS target_exercise_name,
- ex.visibility AS target_exercise_visibility,
- rev.name AS reviewed_by_name,
- asgn.name AS assigned_to_name,
- rep.name AS reporter_profile_name
+ ex.name AS target_exercise_name,
+ ex.visibility AS target_exercise_visibility,
+ rev.name AS reviewed_by_name,
+ asgn.name AS assigned_to_name,
+ rep.name AS reporter_profile_name
FROM content_reports cr
- LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
- LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
- LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
- LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
- LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id
+ LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
+ LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
+ LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
+ LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
+ LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id
WHERE cr.id = %s
""",
(report_id,),
diff --git a/backend/version.py b/backend/version.py
index c9d0572..0340bfc 100644
--- a/backend/version.py
+++ b/backend/version.py
@@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
-APP_VERSION = "0.8.89"
+APP_VERSION = "0.8.90"
BUILD_DATE = "2026-05-11"
-DB_SCHEMA_VERSION = "20260511052"
+DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = {
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
@@ -14,7 +14,7 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
- "media_rights": "1.3.0", # P-11: write_audit_log_entry + legal_hold_set/released events
+ "media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
"media_assets": "1.18.0", # P-11: Legal-Hold nur fuer Superadmin sichtbar (nicht fuer alle Plattform-Admins)
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
@@ -30,10 +30,21 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012)
"maturity_models": "1.4.0", # matrix_stack_bundle: vollständiger Katalog+Modelle+Bindings Export/Import
- "content_reports": "1.2.0", # P-13: Viewer konsolidiert — MediaPreviewModal (geteilt); Melden in ExerciseFormPage-Viewer
+ "content_reports": "1.3.0", # P-13: Club-Admin-Zugriff; Audit-Log; E-Mail (Admin + Melder); target_name-Fix
}
CHANGELOG = [
+ {
+ "version": "0.8.90",
+ "date": "2026-05-11",
+ "changes": [
+ "Fix P-13: Club-Admins sehen jetzt Inhaltsmeldungen ihrer Vereinsmedien in der Inbox (Backend-Filter + Frontend canSeeContentReports).",
+ "Fix P-13: Journaleintrag (content_report_filed) in media_asset_audit_log beim Einreichen einer Meldung (Migration 053).",
+ "Fix P-13: E-Mail-Benachrichtigung an alle Plattform-Admins bei neuer Meldung (best-effort).",
+ "Fix P-13: Bestätigungs-E-Mail an Melder beim Einreichen (best-effort).",
+ "Fix P-13: Medienname (target_filename/target_exercise_name) korrekt in Inbox-Liste angezeigt.",
+ ],
+ },
{
"version": "0.8.89",
"date": "2026-05-11",
diff --git a/frontend/src/context/OrgInboxContext.jsx b/frontend/src/context/OrgInboxContext.jsx
index 68ab9f8..59e82de 100644
--- a/frontend/src/context/OrgInboxContext.jsx
+++ b/frontend/src/context/OrgInboxContext.jsx
@@ -17,8 +17,9 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
-function isPlatformAdmin(user) {
- return user?.role === 'admin' || user?.role === 'superadmin'
+function canSeeContentReports(user) {
+ if (user?.role === 'admin' || user?.role === 'superadmin') return true
+ return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
}
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
@@ -30,7 +31,7 @@ export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
- const canAccessReports = useMemo(() => isPlatformAdmin(user), [user])
+ const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => {
if (!canAccess) {
diff --git a/frontend/src/pages/InboxPage.jsx b/frontend/src/pages/InboxPage.jsx
index 0c95095..4537d8e 100644
--- a/frontend/src/pages/InboxPage.jsx
+++ b/frontend/src/pages/InboxPage.jsx
@@ -512,7 +512,7 @@ export default function InboxPage() {
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
- {rep.target_name ? ` – ${rep.target_name}` : ''}
+ {rep.target_filename || rep.target_exercise_name ? ` – ${rep.target_filename || rep.target_exercise_name}` : ''}