""" 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 (admin/superadmin) - Status-Bearbeitung: Plattform-Admin - Legal Hold aus Meldung: ausschliesslich Superadmin """ from __future__ import annotations 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 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", }) # 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_platform_admin(role: Optional[str]) -> None: if not is_platform_admin(role): raise HTTPException(status_code=403, detail="Nur Plattform-Admins koennen Meldungen bearbeiten") 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 # 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 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": # 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") 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 _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 """ 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" 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) conn.commit() return { "id": result["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), ): """ Alle Meldungen fuer die Admin-Inbox. Sichtbar fuer Plattform-Admins (admin + superadmin). In der InboxPage neben den Join-Requests angezeigt. """ _assert_platform_admin(tenant.global_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 """, ) 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.""" _assert_platform_admin(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.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 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") return _report_row_to_dict(row) @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. """ _assert_platform_admin(tenant.global_role) pid = tenant.profile_id 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.", ) with get_db() as conn: cur = get_cursor(conn) cur.execute("SELECT id, status FROM content_reports WHERE id = %s", (report_id,)) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Meldung nicht gefunden") 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) 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()) 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. Nur Superadmin. 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. """ assert_superadmin_for_legal_hold(tenant.global_role) pid = tenant.profile_id 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"]) 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, }