feat(P-13): implement content reporting enhancements, including email notifications and audit log entries
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 3m41s

This commit is contained in:
Lars 2026-05-11 19:36:23 +02:00
parent 24bf3f7035
commit bacba311ae
7 changed files with 320 additions and 78 deletions

View File

@ -330,7 +330,7 @@ def write_rights_declaration(
def write_audit_log_entry( def write_audit_log_entry(
cur: Any, cur: Any,
asset_id: int, asset_id: int,
acting_profile_id: int, acting_profile_id: Optional[int],
event_type: str, event_type: str,
old_values: dict, old_values: dict,
new_values: dict, new_values: dict,

View File

@ -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'
));

View File

@ -11,12 +11,20 @@ Architektur:
Berechtigungen: Berechtigungen:
- Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt) - 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 - Status-Bearbeitung: Plattform-Admin
- Legal Hold aus Meldung: ausschliesslich Superadmin - 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 from __future__ import annotations
import os
import re import re
from typing import Optional from typing import Optional
@ -30,6 +38,7 @@ from media_legal_hold import (
assert_superadmin_for_legal_hold, assert_superadmin_for_legal_hold,
set_legal_hold, set_legal_hold,
) )
from media_rights import write_audit_log_entry
from tenant_context import TenantContext, get_tenant_context from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["content_reports"]) router = APIRouter(prefix="/api", tags=["content_reports"])
@ -46,6 +55,17 @@ REPORT_REASONS = frozenset({
"other", "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) # Gruende mit hoher Prioritaet (illegal, Minderjaehrigenschutz)
HIGH_PRIORITY_REASONS = frozenset({ HIGH_PRIORITY_REASONS = frozenset({
"minors", "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): if is_platform_admin(global_role):
cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,)) cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,))
return cur.fetchone() is not None return cur.fetchone() is not None
# Sichtbarkeit pruefen: official (immer), club (Mitglied), private (Eigentuemerrechte sind breiter)
cur.execute( cur.execute(
""" """
SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id 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 return cur.fetchone() is not None
if visibility == "private": if visibility == "private":
# Private Medien: Eigentuemerrechte oder gleicher Verein mit club_admin
if asset.get("uploaded_by_profile_id") == profile_id: if asset.get("uploaded_by_profile_id") == profile_id:
return True return True
club_id = asset.get("club_id") 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") 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: def _report_row_to_dict(row) -> dict:
return r2d(row) return r2d(row)
@ -282,6 +390,8 @@ def submit_content_report(
- Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte) - Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte)
- Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf - Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf
- Gutglaubenserklärung (good_faith_confirmed=true) ist Pflicht - 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: if not body.good_faith_confirmed:
raise HTTPException( raise HTTPException(
@ -291,6 +401,9 @@ def submit_content_report(
priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal" priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal"
report_id: int
admin_emails: list[str] = []
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
@ -341,10 +454,43 @@ def submit_content_report(
) )
row = cur.fetchone() row = cur.fetchone()
result = r2d(row) 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() 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 { return {
"id": result["id"], "id": report_id,
"status": result["status"], "status": result["status"],
"priority": result["priority"], "priority": result["priority"],
"submitted_at": str(result["submitted_at"]), "submitted_at": str(result["submitted_at"]),
@ -357,14 +503,20 @@ def list_inbox_content_reports(
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
""" """
Alle Meldungen fuer die Admin-Inbox. Meldungen fuer die Inbox.
Sichtbar fuer Plattform-Admins (admin + superadmin). - Plattform-Admins (admin/superadmin): alle Meldungen
In der InboxPage neben den Join-Requests angezeigt. - 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: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
if is_padmin:
# Plattform-Admin: alle Meldungen
cur.execute( cur.execute(
""" """
SELECT SELECT
@ -385,16 +537,12 @@ def list_inbox_content_reports(
cr.submitted_at, cr.submitted_at,
cr.created_at, cr.created_at,
cr.updated_at, cr.updated_at,
-- Ziel-Medium-Infos (wenn target_type = media_asset)
ma.original_filename AS target_filename, ma.original_filename AS target_filename,
ma.visibility AS target_visibility, ma.visibility AS target_visibility,
ma.media_kind AS target_media_kind, ma.media_kind AS target_media_kind,
ma.legal_hold_active AS target_legal_hold_active, ma.legal_hold_active AS target_legal_hold_active,
-- Ziel-Uebungs-Infos (wenn target_type = exercise)
ex.name AS target_exercise_name, ex.name AS target_exercise_name,
-- Reviewer-Name
rev.name AS reviewed_by_name, rev.name AS reviewed_by_name,
-- Zugewiesen-Name
asgn.name AS assigned_to_name asgn.name AS assigned_to_name
FROM content_reports cr FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset' LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
@ -410,6 +558,72 @@ def list_inbox_content_reports(
LIMIT 500 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()] return [_report_row_to_dict(row) for row in cur.fetchall()]

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.89" APP_VERSION = "0.8.90"
BUILD_DATE = "2026-05-11" BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511052" DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen "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) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "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) "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_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_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 "media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
@ -30,10 +30,21 @@ MODULE_VERSIONS = {
"membership": "1.0.0", "membership": "1.0.0",
"catalogs": "1.5.0", # Updated: Trainer Contexts API (Migration 012) "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 "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 = [ 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", "version": "0.8.89",
"date": "2026-05-11", "date": "2026-05-11",

View File

@ -17,8 +17,9 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin')) return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
} }
function isPlatformAdmin(user) { function canSeeContentReports(user) {
return user?.role === 'admin' || user?.role === 'superadmin' 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 */ /** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
@ -30,7 +31,7 @@ export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([]) const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([]) const [contentReports, setContentReports] = useState([])
const canAccess = useMemo(() => canAccessOrgInbox(user), [user]) const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => isPlatformAdmin(user), [user]) const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => { const refresh = useCallback(async () => {
if (!canAccess) { if (!canAccess) {

View File

@ -512,7 +512,7 @@ export default function InboxPage() {
</span> </span>
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}> <span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id} {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}` : ''}
</span> </span>
</div> </div>
<span <span

View File

@ -1,6 +1,6 @@
// Shinkan Jinkendo Frontend Version // Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.89" export const APP_VERSION = "0.8.90"
export const BUILD_DATE = "2026-05-11" export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = { export const PAGE_VERSIONS = {
@ -27,7 +27,8 @@ export const PAGE_VERSIONS = {
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
InboxPage: "2.0.0", // P-13: Inhaltsmeldungen-Abschnitt integriert InboxPage: "2.1.0", // P-13: target_filename/target_exercise_name fix; Club-Admin-Sicht
OrgInboxContext: "1.1.0", // P-13: canSeeContentReports schliesst Club-Admins ein
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional) MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
ReportContentModal: "1.0.0", // P-13: Melde-Formular (Grund, Beschreibung, Name, E-Mail) ReportContentModal: "1.0.0", // P-13: Melde-Formular (Grund, Beschreibung, Name, E-Mail)
} }