2026-05-11 17:54:53 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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)
|
2026-05-11 19:36:23 +02:00
|
|
|
|
- Liste/Detail: Plattform-Admin (alle Meldungen) oder Club-Admin (nur eigene Vereinsmedien)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
- Status-Bearbeitung: Plattform-Admin
|
|
|
|
|
|
- Legal Hold aus Meldung: ausschliesslich Superadmin
|
2026-05-11 19:36:23 +02:00
|
|
|
|
|
|
|
|
|
|
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')
|
2026-05-11 17:54:53 +02:00
|
|
|
|
"""
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
2026-05-11 19:36:23 +02:00
|
|
|
|
import os
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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,
|
|
|
|
|
|
)
|
2026-05-11 19:36:23 +02:00
|
|
|
|
from media_rights import write_audit_log_entry
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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",
|
|
|
|
|
|
})
|
|
|
|
|
|
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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",
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
# 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
|
|
|
|
|
|
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")
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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
|
2026-05-11 19:36:23 +02:00
|
|
|
|
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
|
|
|
|
|
|
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
"""
|
|
|
|
|
|
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"
|
|
|
|
|
|
|
2026-05-11 19:36:23 +02:00
|
|
|
|
report_id: int
|
|
|
|
|
|
admin_emails: list[str] = []
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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)
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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)
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
conn.commit()
|
|
|
|
|
|
|
2026-05-11 19:36:23 +02:00
|
|
|
|
# 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,
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
return {
|
2026-05-11 19:36:23 +02:00
|
|
|
|
"id": report_id,
|
2026-05-11 17:54:53 +02:00
|
|
|
|
"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),
|
|
|
|
|
|
):
|
|
|
|
|
|
"""
|
2026-05-11 19:36:23 +02:00
|
|
|
|
Meldungen fuer die Inbox.
|
|
|
|
|
|
- Plattform-Admins (admin/superadmin): alle Meldungen
|
|
|
|
|
|
- Club-Admins: nur Meldungen ueber Medien ihres Vereins
|
2026-05-11 17:54:53 +02:00
|
|
|
|
"""
|
2026-05-11 19:36:23 +02:00
|
|
|
|
pid = tenant.profile_id
|
|
|
|
|
|
role = tenant.global_role
|
|
|
|
|
|
|
|
|
|
|
|
is_padmin = is_platform_admin(role)
|
2026-05-11 17:54:53 +02:00
|
|
|
|
|
|
|
|
|
|
with get_db() as conn:
|
|
|
|
|
|
cur = get_cursor(conn)
|
2026-05-11 19:36:23 +02:00
|
|
|
|
|
|
|
|
|
|
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,),
|
|
|
|
|
|
)
|
|
|
|
|
|
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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.*,
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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,
|
2026-05-11 17:54:53 +02:00
|
|
|
|
ma.legal_hold_reason_code AS target_legal_hold_reason_code,
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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
|
2026-05-11 17:54:53 +02:00
|
|
|
|
FROM content_reports cr
|
2026-05-11 19:36:23 +02:00
|
|
|
|
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
|
2026-05-11 17:54:53 +02:00
|
|
|
|
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,
|
|
|
|
|
|
}
|