""" P-13: Content-Melde-Backend Meldeverfahren fuer moeglicherweise rechtswidrige Inhalte (DSA-Grundlage, KRIT-03). Architektur: - Fachliche Daten in 'content_reports' Tabelle (Migration 052) - Integration in bestehende Admin-Inbox (GET /api/me/inbox/content-reports) - Keine separate Admin-Queue; die InboxPage.jsx zeigt beide Vorgangstypen - P-11-Anschluss: Superadmin kann aus Meldung heraus Legal Hold setzen Berechtigungen: - Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt) - 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 from fastapi import APIRouter, Depends, Header, HTTPException from pydantic import BaseModel, Field, field_validator from club_tenancy import is_platform_admin, is_superadmin from db import get_db, get_cursor, r2d from media_legal_hold import ( LEGAL_HOLD_REASON_CODES, 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"]) # Erlaubte Meldungsgruende REPORT_REASONS = frozenset({ "copyright", "image_rights", "privacy", "minors", "illegal_content", "youth_protection", "offensive_content", "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", "illegal_content", "youth_protection", }) # Mapping: report_reason -> legal_hold reason_code _REASON_TO_HOLD_CODE = { "copyright": "copyright_complaint", "image_rights": "rights_dispute", "privacy": "privacy_complaint", "minors": "youth_protection", "illegal_content": "illegal_content", "youth_protection": "youth_protection", "offensive_content": "illegal_content", "other": "other", } _EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$") # ─── Pydantic-Modelle ──────────────────────────────────────────────────────── class ContentReportCreate(BaseModel): target_type: str = Field(default="media_asset") target_id: int = Field(..., ge=1) report_reason: str report_description: str = Field(..., min_length=10, max_length=8000) reporter_name: str = Field(..., min_length=1, max_length=200) reporter_email: str = Field(..., min_length=5, max_length=200) good_faith_confirmed: bool @field_validator("target_type") @classmethod def validate_target_type(cls, v: str) -> str: if v not in ("media_asset", "exercise"): raise ValueError("target_type muss 'media_asset' oder 'exercise' sein") return v @field_validator("report_reason") @classmethod def validate_reason(cls, v: str) -> str: if v not in REPORT_REASONS: raise ValueError(f"Ungueltiger report_reason. Erlaubt: {sorted(REPORT_REASONS)}") return v @field_validator("reporter_email") @classmethod def validate_email(cls, v: str) -> str: if not _EMAIL_RE.match(v.strip()): raise ValueError("Ungueltige E-Mail-Adresse") return v.strip().lower() @field_validator("reporter_name") @classmethod def validate_name(cls, v: str) -> str: v = v.strip() if not v: raise ValueError("Name darf nicht leer sein") return v class ContentReportPatch(BaseModel): status: Optional[str] = None resolution_note: Optional[str] = Field(None, max_length=4000) assigned_to_profile_id: Optional[int] = None @field_validator("status") @classmethod def validate_status(cls, v: Optional[str]) -> Optional[str]: if v is None: return v allowed = {"submitted", "under_review", "resolved_no_action", "resolved_legal_hold", "rejected_invalid"} if v not in allowed: raise ValueError(f"Ungueltiger status. Erlaubt: {sorted(allowed)}") return v class ContentReportLegalHoldBody(BaseModel): reason_note: str = Field(..., min_length=5, max_length=2000) # ─── Hilfsfunktionen ──────────────────────────────────────────────────────── def _assert_can_manage_report(cur, role: Optional[str], pid: int, report: dict) -> None: """Platform-Admins: jede Meldung. Club-Admin: nur Meldungen zu Medien ihres Vereins.""" if is_platform_admin(role): return if report.get("target_type") != "media_asset": raise HTTPException(status_code=403, detail="Nur Plattform-Admins können Meldungen zu Übungen bearbeiten") cur.execute("SELECT club_id FROM media_assets WHERE id = %s", (int(report["target_id"]),)) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Medium nicht gefunden") club_id = r2d(row).get("club_id") if not club_id: raise HTTPException(status_code=403, detail="Kein Zugriff – Medium ohne Vereinszugehörigkeit") cur.execute( """ SELECT 1 FROM club_members cm INNER JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin' """, (pid, club_id), ) if cur.fetchone() is None: raise HTTPException(status_code=403, detail="Kein Zugriff auf diese Meldung") def _assert_can_set_legal_hold_from_report(cur, role: Optional[str], pid: int, asset_id: int) -> None: """Superadmin: immer. Club-Admin: nur für Vereinsmedien mit visibility != 'official'.""" if is_superadmin(role): return cur.execute("SELECT club_id, visibility FROM media_assets WHERE id = %s", (asset_id,)) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Medium nicht gefunden") asset = r2d(row) if asset.get("visibility") == "official": raise HTTPException( status_code=403, detail="Legal Hold auf offiziellen Medien erfordert Superadmin-Rechte", ) club_id = asset.get("club_id") if not club_id: raise HTTPException(status_code=403, detail="Legal Hold erfordert Superadmin-Rechte für Medien ohne Vereinszugehörigkeit") cur.execute( """ SELECT 1 FROM club_members cm INNER JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin' """, (pid, club_id), ) if cur.fetchone() is None: raise HTTPException(status_code=403, detail="Kein Zugriff – Superadmin oder Vereinsadmin des Vereins erforderlich") def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool: """True wenn das Medium als 'official' und aktiv gilt (fuer anonyme Meldung zugaenglich).""" cur.execute( """ SELECT 1 FROM media_assets WHERE id = %s AND visibility = 'official' AND lifecycle_state = 'active' AND (legal_hold_active = FALSE OR legal_hold_active IS NULL) """, (asset_id,), ) return cur.fetchone() is not None def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, global_role: Optional[str]) -> bool: """Prueft ob ein eingeloggter Nutzer das Medium sehen darf.""" if is_platform_admin(global_role): cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,)) return cur.fetchone() is not None cur.execute( """ SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id FROM media_assets ma WHERE ma.id = %s AND ma.lifecycle_state = 'active' AND (ma.legal_hold_active = FALSE OR ma.legal_hold_active IS NULL) """, (asset_id,), ) row = cur.fetchone() if not row: return False asset = r2d(row) visibility = asset.get("visibility") if visibility == "official": return True if visibility == "club": club_id = asset.get("club_id") if not club_id: return False cur.execute( """ SELECT 1 FROM club_members WHERE profile_id = %s AND club_id = %s AND status = 'active' """, (profile_id, club_id), ) return cur.fetchone() is not None if visibility == "private": if asset.get("uploaded_by_profile_id") == profile_id: return True club_id = asset.get("club_id") if club_id: cur.execute( """ SELECT 1 FROM club_members cm INNER JOIN club_member_roles r ON r.club_member_id = cm.id WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin' """, (profile_id, club_id), ) return cur.fetchone() is not None return False def _is_exercise_visible_to_profile(cur, exercise_id: int, profile_id: Optional[int], global_role: Optional[str]) -> bool: """Vereinfachte Sichtbarkeitspruefung fuer Uebungen (fuer Meldungen).""" if profile_id and is_platform_admin(global_role): cur.execute("SELECT 1 FROM exercises WHERE id = %s AND status != 'deleted'", (exercise_id,)) return cur.fetchone() is not None if profile_id is None: cur.execute( "SELECT 1 FROM exercises WHERE id = %s AND visibility = 'official' AND status = 'active'", (exercise_id,), ) return cur.fetchone() is not None cur.execute( """ SELECT e.visibility, e.club_id, e.created_by_profile_id FROM exercises e WHERE e.id = %s AND e.status != 'deleted' """, (exercise_id,), ) row = cur.fetchone() if not row: return False ex = r2d(row) if ex.get("visibility") == "official": return True if ex.get("visibility") == "club": club_id = ex.get("club_id") if not club_id: return False cur.execute( "SELECT 1 FROM club_members WHERE profile_id = %s AND club_id = %s AND status = 'active'", (profile_id, club_id), ) return cur.fetchone() is not None if ex.get("visibility") == "private": return ex.get("created_by_profile_id") == profile_id return False def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[int], Optional[str]]: """Gibt (profile_id, global_role) zurueck wenn Token gueltig, sonst (None, None).""" if not x_auth_token: return None, None cur.execute( """ SELECT p.id AS profile_id, p.role FROM sessions s INNER JOIN profiles p ON p.id = s.profile_id WHERE s.token = %s AND (s.expires_at IS NULL OR s.expires_at > NOW()) """, (x_auth_token,), ) row = cur.fetchone() if not row: return None, None d = r2d(row) 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, target_label: str, 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) prio_label = "DRINGEND" if priority == "high" else "normal" # 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. Sie müssen nichts weiter tun.\n\n" f"Shinkan Jinkendo\n" f"{app_url}" ) _send_email(reporter_email, f"Meldung #{report_id} eingegangen – Shinkan Jinkendo", confirmation_body) # Admin-Benachrichtigung admin_body = ( f"Neue Inhaltsmeldung #{report_id} [{prio_label}]\n\n" f"Meldegrund: {reason_label}\n" f"Gemeldeter Inhalt: {target_label}\n" f"Gemeldet von: {reporter_name} <{reporter_email}>\n\n" f"Posteingang öffnen: {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) # ─── Endpoints ────────────────────────────────────────────────────────────── @router.post("/content-reports", status_code=201) def submit_content_report( body: ContentReportCreate, x_auth_token: Optional[str] = Header(default=None), ): """ Meldung einreichen (optionale Authentifizierung). - 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( status_code=400, detail="Die Gutglaubenserklärung (good_faith_confirmed) muss bestätigt werden.", ) 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) # Optionale Auth aufloesen profile_id, global_role = _resolve_optional_auth(cur, x_auth_token) # Sichtbarkeit pruefen if body.target_type == "media_asset": if profile_id: visible = _is_media_asset_visible_to_profile(cur, body.target_id, profile_id, global_role) else: visible = _is_media_asset_visible_anonymous(cur, body.target_id) if not visible: raise HTTPException( status_code=404, detail="Das gemeldete Medium existiert nicht oder ist nicht zugänglich.", ) elif body.target_type == "exercise": visible = _is_exercise_visible_to_profile(cur, body.target_id, profile_id, global_role) if not visible: raise HTTPException( status_code=404, detail="Die gemeldete Übung existiert nicht oder ist nicht zugänglich.", ) cur.execute( """ INSERT INTO content_reports ( target_type, target_id, report_reason, report_description, reporter_name, reporter_email, reporter_profile_id, good_faith_confirmed, priority, status, submitted_at, created_at, updated_at ) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'submitted', NOW(), NOW(), NOW()) RETURNING id, status, priority, submitted_at """, ( body.target_type, body.target_id, body.report_reason, body.report_description.strip(), body.reporter_name, body.reporter_email, profile_id, body.good_faith_confirmed, priority, ), ) row = cur.fetchone() result = r2d(row) report_id = int(result["id"]) # Dateinamen / Zielbezeichnung fuer E-Mail abfragen target_label_for_email = f"{'Medium' if body.target_type == 'media_asset' else 'Übung'} #{body.target_id}" if body.target_type == "media_asset": cur.execute("SELECT original_filename FROM media_assets WHERE id = %s", (body.target_id,)) fn_row = cur.fetchone() if fn_row: fn = r2d(fn_row).get("original_filename") or "" if fn: target_label_for_email = f"{fn} (Medium #{body.target_id})" elif body.target_type == "exercise": cur.execute("SELECT title FROM exercises WHERE id = %s", (body.target_id,)) ex_row = cur.fetchone() if ex_row: ex_name = r2d(ex_row).get("title") or "" if ex_name: target_label_for_email = f"{ex_name} (Übung #{body.target_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": REASON_LABELS_DE.get(body.report_reason, body.report_reason), "priority": "hoch" if priority == "high" else "normal", }, ) # 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, target_label=target_label_for_email, admin_emails=admin_emails, ) return { "id": report_id, "status": result["status"], "priority": result["priority"], "submitted_at": str(result["submitted_at"]), "message": "Ihre Meldung wurde entgegengenommen. Ein Administrator wird sie prüfen.", } @router.get("/me/inbox/content-reports") def list_inbox_content_reports( tenant: TenantContext = Depends(get_tenant_context), ): """ Meldungen fuer die Inbox. - Plattform-Admins (admin/superadmin): alle Meldungen - Club-Admins: nur Meldungen ueber Medien ihres Vereins """ pid = tenant.profile_id role = tenant.global_role is_padmin = is_platform_admin(role) with get_db() as conn: cur = get_cursor(conn) 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.mime_type AS target_mime_type, ma.legal_hold_active AS target_legal_hold_active, ex.title 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.mime_type AS target_mime_type, ma.legal_hold_active AS target_legal_hold_active, ex.title 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()] @router.get("/content-reports/{report_id}") def get_content_report( report_id: int, tenant: TenantContext = Depends(get_tenant_context), ): """Detail-Ansicht einer Meldung fuer Plattform-Admins und zustaendige Club-Admins.""" pid = tenant.profile_id role = tenant.global_role with get_db() as conn: cur = get_cursor(conn) cur.execute( """ SELECT cr.*, ma.original_filename AS target_filename, ma.visibility AS target_visibility, ma.mime_type AS target_mime_type, ma.legal_hold_active AS target_legal_hold_active, ma.legal_hold_reason_code AS target_legal_hold_reason_code, ex.title 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 WHERE cr.id = %s """, (report_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") report_dict = _report_row_to_dict(row) _assert_can_manage_report(cur, role, pid, report_dict) return report_dict @router.patch("/content-reports/{report_id}") def patch_content_report( report_id: int, body: ContentReportPatch, tenant: TenantContext = Depends(get_tenant_context), ): """ Status und Bearbeitungsnotiz einer Meldung aktualisieren. Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note. Plattform-Admins: jede Meldung. Club-Admins: nur Meldungen zu Medien ihres Vereins. """ pid = tenant.profile_id role = tenant.global_role if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip(): raise HTTPException( status_code=400, detail="Bei Abschluss ohne Massnahme ist eine Begründung (resolution_note) Pflicht.", ) STATUS_LABELS_DE = { "submitted": "Eingegangen", "under_review": "In Bearbeitung", "resolved_no_action": "Abgeschlossen (kein Handlungsbedarf)", "resolved_legal_hold": "Abgeschlossen (Legal Hold)", "rejected_invalid": "Abgewiesen (ungültig)", } with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT id, status, resolution_note, target_type, target_id FROM content_reports WHERE id = %s", (report_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") old_report = r2d(row) _assert_can_manage_report(cur, role, pid, old_report) old_status = old_report["status"] old_note = (old_report.get("resolution_note") or "").strip() updates = ["updated_at = NOW()"] params = [] if body.status is not None: updates.append("status = %s") params.append(body.status) if body.status in ("resolved_no_action", "resolved_legal_hold", "rejected_invalid"): updates.append("reviewed_by_profile_id = %s") updates.append("reviewed_at = NOW()") params.append(pid) elif body.status == "submitted": # Wieder öffnen: Prüferfelder zurücksetzen updates.append("reviewed_by_profile_id = NULL") updates.append("reviewed_at = NULL") if body.resolution_note is not None: updates.append("resolution_note = %s") params.append(body.resolution_note.strip() or None) if body.assigned_to_profile_id is not None: updates.append("assigned_to_profile_id = %s") params.append(body.assigned_to_profile_id) if len(updates) == 1: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren angegeben") params.append(report_id) cur.execute( f"UPDATE content_reports SET {', '.join(updates)} WHERE id = %s RETURNING id, status", params, ) updated = r2d(cur.fetchone()) new_status = updated["status"] is_media = old_report["target_type"] == "media_asset" if is_media: new_note = (body.resolution_note or "").strip() if body.resolution_note is not None else old_note # Audit-Log: Statuswechsel if body.status is not None and old_status != new_status: write_audit_log_entry( cur, int(old_report["target_id"]), pid, "content_report_filed", {"status": STATUS_LABELS_DE.get(old_status, old_status)}, { "content_report_id": report_id, "status": STATUS_LABELS_DE.get(new_status, new_status), **({"begründung": new_note} if new_note else {}), }, ) # Audit-Log: reine Notizänderung (ohne Statuswechsel) elif body.resolution_note is not None and new_note != old_note: write_audit_log_entry( cur, int(old_report["target_id"]), pid, "content_report_filed", {"content_report_id": report_id, "begründung": old_note or None}, {"content_report_id": report_id, "begründung": new_note or None}, ) conn.commit() return {"ok": True, "id": updated["id"], "status": updated["status"]} @router.post("/content-reports/{report_id}/legal-hold") def set_legal_hold_from_report( report_id: int, body: ContentReportLegalHoldBody, tenant: TenantContext = Depends(get_tenant_context), ): """ Legal Hold (P-11) aus einer Meldung heraus setzen. Superadmin: immer. Club-Admin: nur fuer Vereinsmedien (visibility != 'official'). Nur fuer Meldungen mit target_type='media_asset'. Der Reason-Code wird automatisch aus dem report_reason der Meldung abgeleitet. Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt. """ pid = tenant.profile_id role = tenant.global_role # Plattform-Admin ohne Superadmin hat keine Legal-Hold-Rechte (fruehzeitig ablehnen) if is_platform_admin(role) and not is_superadmin(role): raise HTTPException( status_code=403, detail="Legal Hold erfordert Superadmin oder Vereinsadmin-Rechte", ) with get_db() as conn: cur = get_cursor(conn) cur.execute( "SELECT id, target_type, target_id, report_reason, status FROM content_reports WHERE id = %s", (report_id,), ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") report = r2d(row) if report["target_type"] != "media_asset": raise HTTPException( status_code=400, detail="Legal Hold ist nur fuer Meldungen mit target_type='media_asset' verfügbar.", ) asset_id = int(report["target_id"]) _assert_can_set_legal_hold_from_report(cur, role, pid, asset_id) reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other") # P-11-Service aufrufen set_legal_hold( cur=cur, conn=conn, asset_id=asset_id, acting_profile_id=pid, reason_code=reason_code, reason_note=f"[P-13 Meldung #{report_id}] {body.reason_note.strip()}", ) # set_legal_hold committed bereits — neue Transaktion fuer Report-Update cur.execute( """ UPDATE content_reports SET status = 'resolved_legal_hold', reviewed_by_profile_id = %s, reviewed_at = NOW(), resolution_note = %s, updated_at = NOW() WHERE id = %s RETURNING id, status """, (pid, f"Legal Hold gesetzt auf Asset #{asset_id} (reason_code: {reason_code})", report_id), ) updated = r2d(cur.fetchone()) conn.commit() return { "ok": True, "report_id": updated["id"], "report_status": updated["status"], "asset_id": asset_id, "legal_hold_reason_code": reason_code, }