DGSVO Compliance update 1 #30
|
|
@ -193,7 +193,7 @@ def read_root():
|
||||||
return out
|
return out
|
||||||
|
|
||||||
# Register routers
|
# Register routers
|
||||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents
|
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin, legal_documents, content_reports
|
||||||
|
|
||||||
app.include_router(auth.router)
|
app.include_router(auth.router)
|
||||||
app.include_router(profiles.router)
|
app.include_router(profiles.router)
|
||||||
|
|
@ -216,6 +216,7 @@ app.include_router(matrix_stack_bundle.router)
|
||||||
app.include_router(import_wiki.router)
|
app.include_router(import_wiki.router)
|
||||||
app.include_router(import_wiki_admin.router)
|
app.include_router(import_wiki_admin.router)
|
||||||
app.include_router(legal_documents.router)
|
app.include_router(legal_documents.router)
|
||||||
|
app.include_router(content_reports.router)
|
||||||
|
|
||||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||||
|
|
|
||||||
73
backend/migrations/052_content_reports.sql
Normal file
73
backend/migrations/052_content_reports.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
||||||
|
-- P-13: Content-Melde-Backend
|
||||||
|
-- Meldungen rechtswidriger Inhalte (DSA-konformes Meldeverfahren, KRIT-03)
|
||||||
|
--
|
||||||
|
-- Architektur: Diese Tabelle traegt alle fachlichen Report-Daten.
|
||||||
|
-- Die bestehende Admin-Inbox (InboxPage.jsx, GET /api/me/inbox/join-requests)
|
||||||
|
-- wird um einen zweiten Abschnitt erweitert, der Content-Reports anzeigt.
|
||||||
|
-- Keine separate Admin-Queue, keine generische inbox_items-Tabelle.
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS content_reports (
|
||||||
|
id SERIAL PRIMARY KEY,
|
||||||
|
|
||||||
|
-- Ziel der Meldung (erweiterbar auf weitere Typen)
|
||||||
|
target_type VARCHAR(20) NOT NULL DEFAULT 'media_asset'
|
||||||
|
CHECK (target_type IN ('media_asset', 'exercise')),
|
||||||
|
target_id INTEGER NOT NULL,
|
||||||
|
|
||||||
|
-- Meldungsinhalt
|
||||||
|
report_reason VARCHAR(50) NOT NULL
|
||||||
|
CHECK (report_reason IN (
|
||||||
|
'copyright',
|
||||||
|
'image_rights',
|
||||||
|
'privacy',
|
||||||
|
'minors',
|
||||||
|
'illegal_content',
|
||||||
|
'youth_protection',
|
||||||
|
'offensive_content',
|
||||||
|
'other'
|
||||||
|
)),
|
||||||
|
report_description TEXT NOT NULL,
|
||||||
|
|
||||||
|
-- Meldende Person (Name + E-Mail Pflicht; Profil optional bei eingeloggten Nutzern)
|
||||||
|
reporter_name VARCHAR(200) NOT NULL,
|
||||||
|
reporter_email VARCHAR(200) NOT NULL,
|
||||||
|
reporter_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
|
||||||
|
-- Gutglaubenserklärung (Pflicht)
|
||||||
|
good_faith_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||||
|
|
||||||
|
-- Automatische Priorisierung (high fuer minors/youth_protection/illegal_content)
|
||||||
|
priority VARCHAR(20) NOT NULL DEFAULT 'normal'
|
||||||
|
CHECK (priority IN ('high', 'normal')),
|
||||||
|
|
||||||
|
-- Workflow-Status
|
||||||
|
status VARCHAR(30) NOT NULL DEFAULT 'submitted'
|
||||||
|
CHECK (status IN (
|
||||||
|
'submitted',
|
||||||
|
'under_review',
|
||||||
|
'resolved_no_action',
|
||||||
|
'resolved_legal_hold',
|
||||||
|
'rejected_invalid'
|
||||||
|
)),
|
||||||
|
|
||||||
|
-- Bearbeitung
|
||||||
|
assigned_to_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
reviewed_by_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||||
|
reviewed_at TIMESTAMP,
|
||||||
|
resolution_note TEXT,
|
||||||
|
|
||||||
|
-- Zeitstempel
|
||||||
|
submitted_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
created_at TIMESTAMP DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMP DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Indices fuer Admin-Liste
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_reports_status_created
|
||||||
|
ON content_reports (status, created_at DESC);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_reports_target
|
||||||
|
ON content_reports (target_type, target_id);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_content_reports_priority
|
||||||
|
ON content_reports (priority, status, created_at DESC);
|
||||||
584
backend/routers/content_reports.py
Normal file
584
backend/routers/content_reports.py
Normal file
|
|
@ -0,0 +1,584 @@
|
||||||
|
"""
|
||||||
|
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,
|
||||||
|
}
|
||||||
327
backend/tests/test_p13_content_reports.py
Normal file
327
backend/tests/test_p13_content_reports.py
Normal file
|
|
@ -0,0 +1,327 @@
|
||||||
|
"""
|
||||||
|
P-13: Content-Melde-Backend – Backend-Tests.
|
||||||
|
|
||||||
|
Abgedeckt (15 Faelle):
|
||||||
|
1. submit_report ohne good_faith_confirmed=False → 400
|
||||||
|
2. submit_report mit leerem report_description → 422
|
||||||
|
3. submit_report mit fehlender reporter_email → 422
|
||||||
|
4. submit_report ohne Login fuer official-Medium – Erfolgspfad
|
||||||
|
5. submit_report ohne Login fuer nicht-official-Medium → 404
|
||||||
|
6. submit_report mit Login fuer sichtbares club-Medium – Erfolgspfad
|
||||||
|
7. submit_report mit Login fuer nicht-sichtbares fremdes Medium → 404
|
||||||
|
8. Prioritaet high fuer illegal_content/minors/youth_protection
|
||||||
|
9. Prioritaet normal fuer copyright/other
|
||||||
|
10. list_inbox als Nicht-Admin → 403
|
||||||
|
11. list_inbox als Plattform-Admin – gibt Liste zurueck
|
||||||
|
12. patch_report status 'under_review' – Erfolgspfad
|
||||||
|
13. patch_report 'resolved_no_action' ohne resolution_note → 400
|
||||||
|
14. set_legal_hold_from_report als Nicht-Superadmin → 403
|
||||||
|
15. set_legal_hold_from_report als Superadmin – Erfolgspfad (reason_code-Mapping)
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi import HTTPException
|
||||||
|
|
||||||
|
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||||
|
|
||||||
|
from routers.content_reports import (
|
||||||
|
HIGH_PRIORITY_REASONS,
|
||||||
|
_REASON_TO_HOLD_CODE,
|
||||||
|
_is_media_asset_visible_anonymous,
|
||||||
|
_is_media_asset_visible_to_profile,
|
||||||
|
REPORT_REASONS,
|
||||||
|
ContentReportCreate,
|
||||||
|
ContentReportPatch,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Hilfsfunktionen
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def _make_cur(fetchone_val=None):
|
||||||
|
cur = MagicMock()
|
||||||
|
cur.fetchone.return_value = fetchone_val
|
||||||
|
cur.fetchall.return_value = []
|
||||||
|
return cur
|
||||||
|
|
||||||
|
|
||||||
|
def _row(data: dict):
|
||||||
|
"""Simuliert eine psycopg2-DictRow."""
|
||||||
|
m = MagicMock()
|
||||||
|
m.__getitem__ = lambda self, k: data[k]
|
||||||
|
m.keys = lambda: data.keys()
|
||||||
|
return m
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 1. good_faith_confirmed=False → 400
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_submit_requires_good_faith():
|
||||||
|
body = ContentReportCreate(
|
||||||
|
target_type="media_asset",
|
||||||
|
target_id=1,
|
||||||
|
report_reason="copyright",
|
||||||
|
report_description="A" * 20,
|
||||||
|
reporter_name="Max",
|
||||||
|
reporter_email="max@example.com",
|
||||||
|
good_faith_confirmed=False,
|
||||||
|
)
|
||||||
|
from routers.content_reports import submit_content_report
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
submit_content_report(body)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "Gutglauben" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 2. Leere report_description → 422 (Pydantic)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_submit_requires_description():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ContentReportCreate(
|
||||||
|
target_type="media_asset",
|
||||||
|
target_id=1,
|
||||||
|
report_reason="copyright",
|
||||||
|
report_description="ab", # unter min_length=10
|
||||||
|
reporter_name="Max",
|
||||||
|
reporter_email="max@example.com",
|
||||||
|
good_faith_confirmed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 3. Ungueltige E-Mail → 422 (Pydantic Validator)
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_submit_requires_valid_email():
|
||||||
|
with pytest.raises(Exception):
|
||||||
|
ContentReportCreate(
|
||||||
|
target_type="media_asset",
|
||||||
|
target_id=1,
|
||||||
|
report_reason="copyright",
|
||||||
|
report_description="A" * 20,
|
||||||
|
reporter_name="Max",
|
||||||
|
reporter_email="kein-at-zeichen",
|
||||||
|
good_faith_confirmed=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 4. Anonym – official-Medium → sichtbar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_anonymous_official_medium_visible():
|
||||||
|
cur = _make_cur(fetchone_val={"1": 1})
|
||||||
|
cur.fetchone.return_value = MagicMock()
|
||||||
|
result = _is_media_asset_visible_anonymous(cur, asset_id=42)
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 5. Anonym – nicht-official-Medium → nicht sichtbar
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_anonymous_non_official_medium_not_visible():
|
||||||
|
cur = _make_cur(fetchone_val=None)
|
||||||
|
result = _is_media_asset_visible_anonymous(cur, asset_id=99)
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 6. Eingeloggter Nutzer – sichtbares club-Medium → True
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_logged_in_club_member_can_see_club_media():
|
||||||
|
asset_row = MagicMock()
|
||||||
|
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
|
||||||
|
asset_row.__getitem__ = lambda s, k: {
|
||||||
|
"id": 7,
|
||||||
|
"visibility": "club",
|
||||||
|
"club_id": 3,
|
||||||
|
"uploaded_by_profile_id": 99,
|
||||||
|
}[k]
|
||||||
|
|
||||||
|
member_row = MagicMock() # Mitgliedschaft gefunden
|
||||||
|
|
||||||
|
cur = MagicMock()
|
||||||
|
cur.fetchone.side_effect = [asset_row, member_row]
|
||||||
|
|
||||||
|
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=1, global_role="trainer")
|
||||||
|
assert result is True
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 7. Eingeloggter Nutzer – fremdes Medium → False
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_logged_in_non_member_cannot_see_club_media():
|
||||||
|
asset_row = MagicMock()
|
||||||
|
asset_row.keys = lambda: ["id", "visibility", "club_id", "uploaded_by_profile_id"]
|
||||||
|
asset_row.__getitem__ = lambda s, k: {
|
||||||
|
"id": 7,
|
||||||
|
"visibility": "club",
|
||||||
|
"club_id": 3,
|
||||||
|
"uploaded_by_profile_id": 99,
|
||||||
|
}[k]
|
||||||
|
|
||||||
|
cur = MagicMock()
|
||||||
|
# Erster fetchone: Asset-Zeile, zweiter: kein Mitglied
|
||||||
|
cur.fetchone.side_effect = [asset_row, None]
|
||||||
|
|
||||||
|
result = _is_media_asset_visible_to_profile(cur, asset_id=7, profile_id=5, global_role="trainer")
|
||||||
|
assert result is False
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 8. Prioritaet high fuer kritische Gruende
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_high_priority_reasons():
|
||||||
|
assert "illegal_content" in HIGH_PRIORITY_REASONS
|
||||||
|
assert "minors" in HIGH_PRIORITY_REASONS
|
||||||
|
assert "youth_protection" in HIGH_PRIORITY_REASONS
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 9. Prioritaet normal fuer andere Gruende
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_normal_priority_reasons():
|
||||||
|
normal_reasons = REPORT_REASONS - HIGH_PRIORITY_REASONS
|
||||||
|
assert "copyright" in normal_reasons
|
||||||
|
assert "other" in normal_reasons
|
||||||
|
assert "privacy" in normal_reasons
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 10. list_inbox als Nicht-Admin → 403
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_inbox_requires_platform_admin():
|
||||||
|
from routers.content_reports import list_inbox_content_reports
|
||||||
|
tenant = MagicMock()
|
||||||
|
tenant.global_role = "trainer"
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
list_inbox_content_reports(tenant=tenant)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 11. list_inbox als Plattform-Admin – gibt Liste zurueck
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_list_inbox_as_admin_returns_list():
|
||||||
|
from routers.content_reports import list_inbox_content_reports
|
||||||
|
|
||||||
|
tenant = MagicMock()
|
||||||
|
tenant.global_role = "admin"
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchall.return_value = []
|
||||||
|
mock_conn.__enter__ = MagicMock(return_value=mock_conn)
|
||||||
|
mock_conn.__exit__ = MagicMock(return_value=False)
|
||||||
|
mock_conn_ctx = MagicMock()
|
||||||
|
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
|
||||||
|
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
|
||||||
|
patch("routers.content_reports.get_cursor", return_value=mock_cur):
|
||||||
|
result = list_inbox_content_reports(tenant=tenant)
|
||||||
|
assert isinstance(result, list)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 12. patch_report status 'under_review' – Erfolgspfad
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_patch_report_under_review():
|
||||||
|
from routers.content_reports import patch_content_report
|
||||||
|
|
||||||
|
tenant = MagicMock()
|
||||||
|
tenant.global_role = "admin"
|
||||||
|
tenant.profile_id = 1
|
||||||
|
|
||||||
|
body = ContentReportPatch(status="under_review")
|
||||||
|
|
||||||
|
existing_row = MagicMock()
|
||||||
|
existing_row.__getitem__ = lambda s, k: {"id": 5, "status": "submitted"}[k]
|
||||||
|
existing_row.keys = lambda: ["id", "status"]
|
||||||
|
|
||||||
|
updated_row = MagicMock()
|
||||||
|
updated_row.__getitem__ = lambda s, k: {"id": 5, "status": "under_review"}[k]
|
||||||
|
updated_row.keys = lambda: ["id", "status"]
|
||||||
|
|
||||||
|
mock_cur = MagicMock()
|
||||||
|
mock_cur.fetchone.side_effect = [existing_row, updated_row]
|
||||||
|
|
||||||
|
mock_conn = MagicMock()
|
||||||
|
mock_conn_ctx = MagicMock()
|
||||||
|
mock_conn_ctx.__enter__ = MagicMock(return_value=mock_conn)
|
||||||
|
mock_conn_ctx.__exit__ = MagicMock(return_value=False)
|
||||||
|
|
||||||
|
with patch("routers.content_reports.get_db", return_value=mock_conn_ctx), \
|
||||||
|
patch("routers.content_reports.get_cursor", return_value=mock_cur):
|
||||||
|
result = patch_content_report(5, body, tenant=tenant)
|
||||||
|
|
||||||
|
assert result["ok"] is True
|
||||||
|
assert result["status"] == "under_review"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 13. patch_report 'resolved_no_action' ohne resolution_note → 400
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_patch_resolved_no_action_requires_note():
|
||||||
|
from routers.content_reports import patch_content_report
|
||||||
|
|
||||||
|
tenant = MagicMock()
|
||||||
|
tenant.global_role = "admin"
|
||||||
|
tenant.profile_id = 1
|
||||||
|
|
||||||
|
body = ContentReportPatch(status="resolved_no_action", resolution_note=None)
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
patch_content_report(99, body, tenant=tenant)
|
||||||
|
assert exc.value.status_code == 400
|
||||||
|
assert "Begründung" in exc.value.detail
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 14. set_legal_hold_from_report als Nicht-Superadmin → 403
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_legal_hold_from_report_requires_superadmin():
|
||||||
|
from routers.content_reports import set_legal_hold_from_report, ContentReportLegalHoldBody
|
||||||
|
|
||||||
|
tenant = MagicMock()
|
||||||
|
tenant.global_role = "admin" # nur admin, nicht superadmin
|
||||||
|
|
||||||
|
body = ContentReportLegalHoldBody(reason_note="Test-Begruendung")
|
||||||
|
|
||||||
|
with pytest.raises(HTTPException) as exc:
|
||||||
|
set_legal_hold_from_report(1, body, tenant=tenant)
|
||||||
|
assert exc.value.status_code == 403
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# 15. Reason-Code-Mapping: copyright → copyright_complaint
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
def test_reason_code_mapping():
|
||||||
|
assert _REASON_TO_HOLD_CODE["copyright"] == "copyright_complaint"
|
||||||
|
assert _REASON_TO_HOLD_CODE["minors"] == "youth_protection"
|
||||||
|
assert _REASON_TO_HOLD_CODE["illegal_content"] == "illegal_content"
|
||||||
|
assert _REASON_TO_HOLD_CODE["privacy"] == "privacy_complaint"
|
||||||
|
assert _REASON_TO_HOLD_CODE["image_rights"] == "rights_dispute"
|
||||||
|
assert _REASON_TO_HOLD_CODE["other"] == "other"
|
||||||
|
# Alle Reason-Codes muessen gemappt sein
|
||||||
|
for reason in REPORT_REASONS:
|
||||||
|
assert reason in _REASON_TO_HOLD_CODE, f"{reason} fehlt im Mapping"
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.86"
|
APP_VERSION = "0.8.87"
|
||||||
BUILD_DATE = "2026-05-11"
|
BUILD_DATE = "2026-05-11"
|
||||||
DB_SCHEMA_VERSION = "20260511051"
|
DB_SCHEMA_VERSION = "20260511052"
|
||||||
|
|
||||||
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
|
||||||
|
|
@ -30,9 +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.0.0", # P-13: Content-Melde-Backend (DSA-konform, Inbox-Integration, P-11 Legal Hold)
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
CHANGELOG = [
|
||||||
|
{
|
||||||
|
"version": "0.8.87",
|
||||||
|
"date": "2026-05-11",
|
||||||
|
"changes": [
|
||||||
|
"Feat P-13: Content-Melde-Backend (DSA/KRIT-03) — Migration 052 content_reports; POST /api/content-reports (optionale Auth, official+aktive Medien ohne Login meldbar); GET /api/me/inbox/content-reports (Plattform-Admin); PATCH /api/content-reports/{id}; POST /api/content-reports/{id}/legal-hold (Superadmin, P-11 Integration).",
|
||||||
|
"Feat P-13: Automatische Priorisierung high fuer minors/illegal_content/youth_protection.",
|
||||||
|
"Feat P-13: Inbox-Integration — zweiter Abschnitt 'Inhaltsmeldungen' in InboxPage.jsx; OrgInboxContext liefert contentReports + contentReportCount.",
|
||||||
|
"Feat P-13: P-11 Legal-Hold via set_legal_hold() aus report heraus (reason_code-Mapping); keine separate Moderations-Queue.",
|
||||||
|
"Tests: 15 Backend-Unit-Tests test_p13_content_reports.py.",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.86",
|
"version": "0.8.86",
|
||||||
"date": "2026-05-11",
|
"date": "2026-05-11",
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Erstellt:** 2026-05-09
|
**Erstellt:** 2026-05-09
|
||||||
**Zuletzt aktualisiert:** 2026-05-11
|
**Zuletzt aktualisiert:** 2026-05-11
|
||||||
**Audit-Basis:** `docs/compliance-audit.md`
|
**Audit-Basis:** `docs/compliance-audit.md`
|
||||||
**App-Version nach Umsetzung:** 0.8.86
|
**App-Version nach Umsetzung:** 0.8.87
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -523,6 +523,50 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
### P-13 – Content-Melde-Backend ✅
|
||||||
|
|
||||||
|
**Status:** Vollständig umgesetzt (2026-05-11, Version 0.8.87)
|
||||||
|
**Finding:** KRIT-03
|
||||||
|
|
||||||
|
**Architekturentscheidung:** Anstelle einer separaten Moderations-Queue wurde die bestehende Admin-Inbox (`InboxPage.jsx`) um einen zweiten Abschnitt erweitert. Keine generische `inbox_items`-Tabelle, keine separate `/api/admin/reports`-Queue.
|
||||||
|
|
||||||
|
**Betroffene Dateien:**
|
||||||
|
- `backend/migrations/052_content_reports.sql` (neu) — Tabelle `content_reports` mit Status-Workflow, Priorisierung, 3 Indizes
|
||||||
|
- `backend/routers/content_reports.py` (neu) — alle Endpoints
|
||||||
|
- `backend/tests/test_p13_content_reports.py` (neu) — 15 Unit-Tests
|
||||||
|
- `backend/main.py` — Router-Registrierung
|
||||||
|
- `backend/version.py` — Version 0.8.87, content_reports 1.0.0
|
||||||
|
- `frontend/src/utils/api.js` — 5 neue API-Funktionen (submitContentReport, getInboxContentReports, getContentReport, patchContentReport, setLegalHoldFromReport)
|
||||||
|
- `frontend/src/context/OrgInboxContext.jsx` — contentReports-State, contentReportCount, canAccessContentReports, isSuperadmin
|
||||||
|
- `frontend/src/pages/InboxPage.jsx` — zweiter Abschnitt „Inhaltsmeldungen", ReportDetailModal
|
||||||
|
|
||||||
|
**API-Endpoints:**
|
||||||
|
|
||||||
|
| Endpoint | Auth | Beschreibung |
|
||||||
|
|----------|------|--------------|
|
||||||
|
| `POST /api/content-reports` | Optional | Meldung einreichen (official-Medien ohne Login; eingeloggt: alle sichtbaren Medien) |
|
||||||
|
| `GET /api/me/inbox/content-reports` | Plattform-Admin | Liste aller Meldungen, JOIN auf Zieltabellen |
|
||||||
|
| `GET /api/content-reports/{id}` | Plattform-Admin | Einzel-Detail |
|
||||||
|
| `PATCH /api/content-reports/{id}` | Plattform-Admin | Status/Notiz/Zuweisung; resolution_note Pflicht für Abschluss-Status |
|
||||||
|
| `POST /api/content-reports/{id}/legal-hold` | Superadmin | Legal Hold via P-11 `set_legal_hold()`; setzt Status auf `resolved_legal_hold` |
|
||||||
|
|
||||||
|
**Reason-Code-Mapping (P-13 → P-11):**
|
||||||
|
|
||||||
|
| Meldegrund | Legal-Hold reason_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 |
|
||||||
|
|
||||||
|
**Nicht in P-13-Scope:** P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung per E-Mail), P-16 (Beschwerdeverfahren). SMTP-Infrastruktur vorhanden; E-Mail-Benachrichtigungen folgen in P-15.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## Nicht umgesetzte Pakete
|
## Nicht umgesetzte Pakete
|
||||||
|
|
||||||
> Paket-IDs und -Titel gemäß kanonischem Register `docs/compliance-package-register.md`.
|
> Paket-IDs und -Titel gemäß kanonischem Register `docs/compliance-package-register.md`.
|
||||||
|
|
@ -538,7 +582,7 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
|
||||||
| P-10 | Mindestalter-Abfrage | offen | Scope ausgeschlossen |
|
| P-10 | Mindestalter-Abfrage | offen | Scope ausgeschlossen |
|
||||||
| P-11 | Legal-Hold Lifecycle-Status | ✅ umgesetzt | Version 0.8.84–0.8.86 — siehe §P-11 oben |
|
| P-11 | Legal-Hold Lifecycle-Status | ✅ umgesetzt | Version 0.8.84–0.8.86 — siehe §P-11 oben |
|
||||||
| P-12 | sessionStorage bei Logout bereinigen | ✅ umgesetzt | Version 0.8.68 — siehe §P-12 oben |
|
| P-12 | sessionStorage bei Logout bereinigen | ✅ umgesetzt | Version 0.8.68 — siehe §P-12 oben |
|
||||||
| P-13 | Content-Melde-Backend | offen | Scope ausgeschlossen (erst juristisch klären) |
|
| P-13 | Content-Melde-Backend | ✅ umgesetzt | Version 0.8.87 — siehe §P-13 unten |
|
||||||
| P-14 | Moderations-UI | offen | Scope ausgeschlossen |
|
| P-14 | Moderations-UI | offen | Scope ausgeschlossen |
|
||||||
| P-15 | Uploader-Benachrichtigung bei Sperrung | offen | Scope ausgeschlossen |
|
| P-15 | Uploader-Benachrichtigung bei Sperrung | offen | Scope ausgeschlossen |
|
||||||
| P-16 | Beschwerdeverfahren | offen | Scope ausgeschlossen |
|
| P-16 | Beschwerdeverfahren | offen | Scope ausgeschlossen |
|
||||||
|
|
@ -553,7 +597,7 @@ Referenz: `docs/p06-upload-rights-spec.md` §10.5, §11.9.
|
||||||
|
|
||||||
## Re-Audit-Empfehlung
|
## Re-Audit-Empfehlung
|
||||||
|
|
||||||
Operativ prüfen (Stand 0.8.86):
|
Operativ prüfen (Stand 0.8.87):
|
||||||
|
|
||||||
1. **P-03/P-03b**: `docker logs shinkan-retention-cron` — Job läuft täglich 03:00 Uhr; Retention-Zeiten: 30 → 30 Tage
|
1. **P-03/P-03b**: `docker logs shinkan-retention-cron` — Job läuft täglich 03:00 Uhr; Retention-Zeiten: 30 → 30 Tage
|
||||||
2. **P-04**: Manuell: PATCH privates Medium auf `official` ohne `copyright_notice` → muss 400 liefern
|
2. **P-04**: Manuell: PATCH privates Medium auf `official` ohne `copyright_notice` → muss 400 liefern
|
||||||
|
|
@ -562,5 +606,6 @@ Operativ prüfen (Stand 0.8.86):
|
||||||
5. **P-06**: Manuell: Upload ohne `rights_holder_confirmed` → muss 400 liefern; Journal-Endpoint für vorhandene Assets → muss 200 + `events[]` liefern; Korrektur-Endpoint → muss neue Deklaration mit `action_type='correction'` schreiben
|
5. **P-06**: Manuell: Upload ohne `rights_holder_confirmed` → muss 400 liefern; Journal-Endpoint für vorhandene Assets → muss 200 + `events[]` liefern; Korrektur-Endpoint → muss neue Deklaration mit `action_type='correction'` schreiben
|
||||||
6. **P-06 Audit-Log**: PATCH Sichtbarkeit eines Assets → `media_asset_audit_log` muss Eintrag `visibility_change` enthalten
|
6. **P-06 Audit-Log**: PATCH Sichtbarkeit eines Assets → `media_asset_audit_log` muss Eintrag `visibility_change` enthalten
|
||||||
7. **P-11**: Superadmin → Medium sperren → in Übung öffnen → Kachel zeigt „Gesperrt"; direkter Dateiaufruf `/exercises/{id}/media/{mid}/file` → muss HTTP 451 liefern; Plattform-Admin (kein Superadmin) → gesperrtes Medium darf in Medienliste nicht erscheinen
|
7. **P-11**: Superadmin → Medium sperren → in Übung öffnen → Kachel zeigt „Gesperrt"; direkter Dateiaufruf `/exercises/{id}/media/{mid}/file` → muss HTTP 451 liefern; Plattform-Admin (kein Superadmin) → gesperrtes Medium darf in Medienliste nicht erscheinen
|
||||||
|
8. **P-13**: Anonym → `POST /api/content-reports` für ein official-Medium ohne Auth → muss 200 liefern; gültige Meldung ohne `good_faith_confirmed=true` → muss 400 liefern; Plattform-Admin → `GET /api/me/inbox/content-reports` → Liste; Superadmin → `POST /api/content-reports/{id}/legal-hold` → setzt Legal Hold + Report-Status `resolved_legal_hold`
|
||||||
|
|
||||||
Nächster vollständiger Re-Audit empfohlen nach juristischer Klärung P-06/KRIT-04 (Textfreigabe T1–T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.
|
Nächster vollständiger Re-Audit empfohlen nach juristischer Klärung P-06/KRIT-04 (Textfreigabe T1–T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@
|
||||||
**Typ:** Kanonisches Referenzdokument
|
**Typ:** Kanonisches Referenzdokument
|
||||||
**Erstellt:** 2026-05-10
|
**Erstellt:** 2026-05-10
|
||||||
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
|
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
|
||||||
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.86)
|
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.87)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -266,11 +266,11 @@
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| **Kanonischer Titel** | Content-Melde-Backend (content_reports-Tabelle + Endpoints) |
|
| **Kanonischer Titel** | Content-Melde-Backend (content_reports-Tabelle + Endpoints) |
|
||||||
| **Findings** | KRIT-03 |
|
| **Findings** | KRIT-03 |
|
||||||
| **Etappe** | 3 |
|
| **Etappe** | 3 → B (vorgezogen; → `docs/compliance-roadmap.md` §4) |
|
||||||
| **Status** | ❌ open |
|
| **Status** | ✅ implemented |
|
||||||
| **Letzter Stand** | Nicht umgesetzt. Juristisch zu klären (DSA-Anwendungsbereich). Technische Spec vorhanden in `docs/compliance-audit.md` §17. |
|
| **Letzter Stand** | Vollständig umgesetzt (2026-05-11, Version 0.8.87). Migration 052 (`content_reports`-Tabelle, Indizes). `POST /api/content-reports`: optionale Auth via `X-Auth-Token`, official+aktive Medien ohne Login meldbar, good_faith_confirmed-Pflicht, automatische Priorisierung (high für minors/illegal_content/youth_protection). `GET /api/me/inbox/content-reports`: Plattform-Admin, JOIN auf Zieltabellen. `GET /api/content-reports/{id}`: Admin-Detail. `PATCH /api/content-reports/{id}`: Status/Notiz/Zuweisung (resolution_note für Abschluss-Status Pflicht). `POST /api/content-reports/{id}/legal-hold`: Superadmin, ruft P-11 `set_legal_hold()` auf (reason_code-Mapping), setzt Report-Status auf `resolved_legal_hold`. Inbox-Integration: Zweiter Abschnitt „Inhaltsmeldungen" in `InboxPage.jsx`; `OrgInboxContext` liefert `contentReports` + `contentReportCount`. Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. 15 Backend-Unit-Tests (`test_p13_content_reports.py`). |
|
||||||
| **Verweise** | `docs/compliance-audit.md` §12, §17 |
|
| **Verweise** | `docs/compliance-audit.md` §12, §17; `backend/migrations/052_content_reports.sql`; `backend/routers/content_reports.py`; `backend/tests/test_p13_content_reports.py`; `frontend/src/pages/InboxPage.jsx`; `frontend/src/context/OrgInboxContext.jsx`; `frontend/src/utils/api.js` |
|
||||||
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
|
| **Hinweise** | Architekturentscheidung: Die bestehende Admin-Inbox (`InboxPage.jsx`) wurde um einen zweiten Abschnitt erweitert statt einer separaten Moderations-Queue. P-14 (Moderations-UI als eigenständige Seite), P-15 (Uploader-Benachrichtigung) und P-16 (Beschwerdeverfahren) folgen als eigenständige Pakete in Etappe D. E-Mail-Bestätigungen sind nicht in P-13-Scope (SMTP-Infrastruktur vorhanden, P-15 zugeordnet). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
@ -406,7 +406,7 @@
|
||||||
| P-10 | Mindestalter-Abfrage | 2 | HOCH-06 | ❌ open |
|
| P-10 | Mindestalter-Abfrage | 2 | HOCH-06 | ❌ open |
|
||||||
| P-11 | Legal-Hold Lifecycle-Status | 2 | MITT-02 | ✅ implemented |
|
| P-11 | Legal-Hold Lifecycle-Status | 2 | MITT-02 | ✅ implemented |
|
||||||
| P-12 | sessionStorage bei Logout bereinigen | 2 | MITT-05 | ✅ implemented |
|
| P-12 | sessionStorage bei Logout bereinigen | 2 | MITT-05 | ✅ implemented |
|
||||||
| P-13 | Content-Melde-Backend | 3 | KRIT-03 | ❌ open |
|
| P-13 | Content-Melde-Backend | 3→B | KRIT-03 | ✅ implemented |
|
||||||
| P-14 | Moderations-UI | 3 | KRIT-03 | ❌ open |
|
| P-14 | Moderations-UI | 3 | KRIT-03 | ❌ open |
|
||||||
| P-15 | Uploader-Benachrichtigung bei Sperrung | 3 | KRIT-03 | ❌ open |
|
| P-15 | Uploader-Benachrichtigung bei Sperrung | 3 | KRIT-03 | ❌ open |
|
||||||
| P-16 | Beschwerdeverfahren | 3 | KRIT-03 | ❌ open |
|
| P-16 | Beschwerdeverfahren | 3 | KRIT-03 | ❌ open |
|
||||||
|
|
@ -423,11 +423,11 @@
|
||||||
|
|
||||||
## Fortschritt
|
## Fortschritt
|
||||||
|
|
||||||
**Implementiert (vollständig):** P-03, P-03b, P-04, P-05, P-05b, P-07, P-11, P-12, P-23, P-24 — 10 Pakete (inkl. 2 Nacharbeiten)
|
**Implementiert (vollständig):** P-03, P-03b, P-04, P-05, P-05b, P-07, P-11, P-12, P-13, P-23, P-24 — 11 Pakete (inkl. 2 Nacharbeiten)
|
||||||
**Teilweise implementiert:** P-01 (technischer Teil vollständig inkl. P-01b, P-01c, copy-as-draft, jsPDF; juristische Inhalte offen) — 1 Paket
|
**Teilweise implementiert:** P-01 (technischer Teil vollständig inkl. P-01b, P-01c, copy-as-draft, jsPDF; juristische Inhalte offen) — 1 Paket
|
||||||
**Teilweise umgesetzt (KRIT offen):** P-06 (Upload-Einwilligungsdialog inkl. P-06+ Volljournal + Korrektur — KRIT-04 bis juristische Validierung ausstehend)
|
**Teilweise umgesetzt (KRIT offen):** P-06 (Upload-Einwilligungsdialog inkl. P-06+ Volljournal + Korrektur — KRIT-04 bis juristische Validierung ausstehend)
|
||||||
**Offen:** P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22 — 14 Pakete
|
**Offen:** P-02, P-08, P-09, P-10, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22 — 13 Pakete
|
||||||
**App-Version bei letzter Aktualisierung:** 0.8.86
|
**App-Version bei letzter Aktualisierung:** 0.8.87
|
||||||
**Letztes Umsetzungsdatum:** 2026-05-11
|
**Letztes Umsetzungsdatum:** 2026-05-11
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@
|
||||||
|
|
||||||
**Typ:** Lebendes Steuerungsdokument
|
**Typ:** Lebendes Steuerungsdokument
|
||||||
**Erstellt:** 2026-05-10
|
**Erstellt:** 2026-05-10
|
||||||
**App-Version:** 0.8.86
|
**App-Version:** 0.8.87
|
||||||
**Zuletzt aktualisiert:** 2026-05-11
|
**Zuletzt aktualisiert:** 2026-05-11
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
@ -29,7 +29,7 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
|
||||||
|
|
||||||
## 2. Aktueller Stand (2026-05-11)
|
## 2. Aktueller Stand (2026-05-11)
|
||||||
|
|
||||||
### App-Version: 0.8.86
|
### App-Version: 0.8.87
|
||||||
|
|
||||||
### Teilweise umgesetzte Pakete
|
### Teilweise umgesetzte Pakete
|
||||||
|
|
||||||
|
|
@ -55,13 +55,14 @@ Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bis
|
||||||
| P-01c | _Nacharbeit:_ Admin-konfigurierbare Rechtstexte (DB 047, Superadmin-UI, API) | 0.8.71 |
|
| P-01c | _Nacharbeit:_ Admin-konfigurierbare Rechtstexte (DB 047, Superadmin-UI, API) | 0.8.71 |
|
||||||
| P-01c+ | _Erweiterung:_ Als-Entwurf-kopieren (`copy-as-draft`) | 0.8.72 |
|
| P-01c+ | _Erweiterung:_ Als-Entwurf-kopieren (`copy-as-draft`) | 0.8.72 |
|
||||||
| P-01c++ | _Erweiterung:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 |
|
| P-01c++ | _Erweiterung:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 |
|
||||||
|
| P-13 | Content-Melde-Backend (Minimalversion, DSA/KRIT-03) | 0.8.87 |
|
||||||
|
|
||||||
**Vollständig abgeschlossen:** 9 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 15 Umsetzungseinheiten
|
**Vollständig abgeschlossen:** 10 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 16 Umsetzungseinheiten
|
||||||
**Teilweise umgesetzt (technisch):** P-01, P-06
|
**Teilweise umgesetzt (technisch):** P-01, P-06
|
||||||
|
|
||||||
### Offene Pakete (14)
|
### Offene Pakete (13)
|
||||||
|
|
||||||
P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
|
P-02, P-08, P-09, P-10, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-22
|
||||||
|
|
||||||
### Gesamtstatus
|
### Gesamtstatus
|
||||||
|
|
||||||
|
|
@ -72,7 +73,7 @@ P-02, P-08, P-09, P-10, P-13, P-14, P-15, P-16, P-17, P-18, P-19, P-20, P-21, P-
|
||||||
1. **P-01:** Kein Impressum, keine Datenschutzerklärung, keine AGB. Eine öffentlich erreichbare App ohne Impressum ist eine Ordnungswidrigkeit nach § 5 DDG.
|
1. **P-01:** Kein Impressum, keine Datenschutzerklärung, keine AGB. Eine öffentlich erreichbare App ohne Impressum ist eine Ordnungswidrigkeit nach § 5 DDG.
|
||||||
2. **P-06:** Keine Einwilligungserklärung beim Medienupload. Personenbilder können ohne jede Rechteabklärung hochgeladen werden.
|
2. **P-06:** Keine Einwilligungserklärung beim Medienupload. Personenbilder können ohne jede Rechteabklärung hochgeladen werden.
|
||||||
3. **P-02:** Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Der Prozess ist noch nicht einmal fachlich spezifiziert.
|
3. **P-02:** Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Der Prozess ist noch nicht einmal fachlich spezifiziert.
|
||||||
4. **P-13:** Keine Möglichkeit, rechtswidrige Inhalte zu melden. Bei einer UGC-Plattform mit öffentlichen Inhalten ist dies ein erhebliches Restrisiko, unabhängig davon, ob der DSA formal anwendbar ist.
|
4. ~~**P-13:** Keine Möglichkeit, rechtswidrige Inhalte zu melden.~~ ✅ **Implementiert (v0.8.87).** Content-Melde-Backend umgesetzt: POST `/api/content-reports`, Admin-Inbox-Integration, P-11 Legal-Hold-Anbindung.
|
||||||
5. ~~**P-11:** Kein Legal-Hold-Status.~~ ✅ **Implementiert (v0.8.84).**
|
5. ~~**P-11:** Kein Legal-Hold-Status.~~ ✅ **Implementiert (v0.8.84).**
|
||||||
|
|
||||||
**Was geöffnet bleiben kann (mit Dokumentation):** Vereinsinterne Nutzung durch bekannte Trainer-Gruppen ist mit dem aktuellen Stand vertretbar, solange keine öffentliche Vermarktung stattfindet und kein `official`-Content aktiviert wird.
|
**Was geöffnet bleiben kann (mit Dokumentation):** Vereinsinterne Nutzung durch bekannte Trainer-Gruppen ist mit dem aktuellen Stand vertretbar, solange keine öffentliche Vermarktung stattfindet und kein `official`-Content aktiviert wird.
|
||||||
|
|
@ -112,12 +113,10 @@ Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registri
|
||||||
**Finding:** MITT-02
|
**Finding:** MITT-02
|
||||||
**Abgeschlossen:** 2026-05-11. Migration 051, `media_legal_hold.py`, Retention-Schutz, Superadmin-API + Frontend (0.8.84); UI-Bugfixes (0.8.85); vollständige Auslieferungssperre (`download_exercise_media_file` HTTP 451), Frontend-Placeholder in allen Übungskomponenten, Medienliste nur noch für Superadmin mit Legal-Hold-Einträgen (0.8.86). Details: `docs/compliance-package-register.md` §P-11.
|
**Abgeschlossen:** 2026-05-11. Migration 051, `media_legal_hold.py`, Retention-Schutz, Superadmin-API + Frontend (0.8.84); UI-Bugfixes (0.8.85); vollständige Auslieferungssperre (`download_exercise_media_file` HTTP 451), Frontend-Placeholder in allen Übungskomponenten, Medienliste nur noch für Superadmin mit Legal-Hold-Einträgen (0.8.86). Details: `docs/compliance-package-register.md` §P-11.
|
||||||
|
|
||||||
### Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)
|
### ~~Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)~~ ✅ Implementiert (v0.8.87)
|
||||||
|
|
||||||
**Finding:** KRIT-03
|
**Finding:** KRIT-03
|
||||||
**Warum vorgezogen (Abweichung vom Initial-Audit):** → Siehe §4
|
**Abgeschlossen:** 2026-05-11. Migration 052 (`content_reports`); `POST /api/content-reports` (optionale Auth, official-Medien ohne Login meldbar); `GET /api/me/inbox/content-reports` (Plattform-Admin); `PATCH /api/content-reports/{id}`; `POST /api/content-reports/{id}/legal-hold` (Superadmin, P-11-Integration, reason_code-Mapping). Inbox-Erweiterung `InboxPage.jsx` mit zweitem Abschnitt „Inhaltsmeldungen". Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. 15 Backend-Unit-Tests. Details: `docs/compliance-package-register.md` §P-13.
|
||||||
**Minimalumfang:** Meldung einreichen + Moderations-Queue für Admin. Nicht der volle P-14–P-16-Stack.
|
|
||||||
**Abhängigkeit:** ~~P-11 (Legal-Hold)~~ ✅ bereits implementiert.
|
|
||||||
|
|
||||||
### Blocker 5 — P-02: DSGVO-Self-Service (fachliche Spezifikation zuerst)
|
### Blocker 5 — P-02: DSGVO-Self-Service (fachliche Spezifikation zuerst)
|
||||||
|
|
||||||
|
|
@ -176,16 +175,11 @@ Reihenfolge innerhalb der Etappe ist flexibel; P-01 und P-06 haben keine gegense
|
||||||
| P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 2–4 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
|
| P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 2–4 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
|
||||||
| P-06 | Upload-Einwilligungsdialog | 2–4 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
|
| P-06 | Upload-Einwilligungsdialog | 2–4 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
|
||||||
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
|
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
|
||||||
| P-13 | Content-Melde-Backend (Minimalversion) | 3–5 Tage | P-11 ✅ bereits erfüllt | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
|
| ~~P-13~~ | ~~Content-Melde-Backend (Minimalversion)~~ | — | ✅ implementiert (v0.8.87) | — |
|
||||||
|
|
||||||
**Hinweis P-01:** Die technische Anlage (leere Seiten, Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`) ist von der juristischen Ausarbeitung des Inhalts zu trennen. Beide Teilschritte können unabhängig voneinander freigegeben und durchgeführt werden.
|
**Hinweis P-01:** Die technische Anlage (leere Seiten, Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`) ist von der juristischen Ausarbeitung des Inhalts zu trennen. Beide Teilschritte können unabhängig voneinander freigegeben und durchgeführt werden.
|
||||||
|
|
||||||
**Hinweis P-13 Minimalumfang:** Im Rahmen von Etappe B umfasst P-13 ausschließlich:
|
**Hinweis P-13:** ✅ Implementiert (v0.8.87). Endpunkte und Architektur: `POST /api/content-reports`, `GET /api/me/inbox/content-reports`, `PATCH /api/content-reports/{id}`, `POST /api/content-reports/{id}/legal-hold`. Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung), P-16 (Beschwerdeverfahren) folgen in Etappe D.
|
||||||
- `POST /api/reports` — Meldung einreichen
|
|
||||||
- `GET /api/admin/reports` — Moderations-Queue
|
|
||||||
- `PATCH /api/admin/reports/{id}` — Status setzen
|
|
||||||
|
|
||||||
P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung), P-16 (Beschwerdeverfahren) folgen in Etappe D.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,10 @@ 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) {
|
||||||
|
return user?.role === 'admin' || user?.role === 'superadmin'
|
||||||
|
}
|
||||||
|
|
||||||
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
|
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
|
||||||
export function notifyOrgInboxChanged() {
|
export function notifyOrgInboxChanged() {
|
||||||
window.dispatchEvent(new Event('shinkan:inbox-changed'))
|
window.dispatchEvent(new Event('shinkan:inbox-changed'))
|
||||||
|
|
@ -24,44 +28,66 @@ export function notifyOrgInboxChanged() {
|
||||||
|
|
||||||
export function OrgInboxProvider({ user, children }) {
|
export function OrgInboxProvider({ user, children }) {
|
||||||
const [items, setItems] = useState([])
|
const [items, setItems] = useState([])
|
||||||
|
const [contentReports, setContentReports] = useState([])
|
||||||
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
|
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
|
||||||
|
const canAccessReports = useMemo(() => isPlatformAdmin(user), [user])
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
if (!canAccess) {
|
if (!canAccess) {
|
||||||
setItems([])
|
setItems([])
|
||||||
return
|
} else {
|
||||||
|
try {
|
||||||
|
const data = await api.getInboxJoinRequests()
|
||||||
|
setItems(Array.isArray(data) ? data : [])
|
||||||
|
} catch {
|
||||||
|
setItems([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
try {
|
|
||||||
const data = await api.getInboxJoinRequests()
|
if (!canAccessReports) {
|
||||||
setItems(Array.isArray(data) ? data : [])
|
setContentReports([])
|
||||||
} catch {
|
} else {
|
||||||
setItems([])
|
try {
|
||||||
|
const data = await api.getInboxContentReports()
|
||||||
|
setContentReports(Array.isArray(data) ? data : [])
|
||||||
|
} catch {
|
||||||
|
setContentReports([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}, [canAccess])
|
}, [canAccess, canAccessReports])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!canAccess) {
|
if (!canAccess && !canAccessReports) {
|
||||||
setItems([])
|
setItems([])
|
||||||
|
setContentReports([])
|
||||||
return undefined
|
return undefined
|
||||||
}
|
}
|
||||||
let cancelled = false
|
let cancelled = false
|
||||||
;(async () => {
|
;(async () => {
|
||||||
try {
|
if (canAccess) {
|
||||||
const data = await api.getInboxJoinRequests()
|
try {
|
||||||
if (!cancelled) setItems(Array.isArray(data) ? data : [])
|
const data = await api.getInboxJoinRequests()
|
||||||
} catch {
|
if (!cancelled) setItems(Array.isArray(data) ? data : [])
|
||||||
if (!cancelled) setItems([])
|
} catch {
|
||||||
|
if (!cancelled) setItems([])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (canAccessReports) {
|
||||||
|
try {
|
||||||
|
const data = await api.getInboxContentReports()
|
||||||
|
if (!cancelled) setContentReports(Array.isArray(data) ? data : [])
|
||||||
|
} catch {
|
||||||
|
if (!cancelled) setContentReports([])
|
||||||
|
}
|
||||||
}
|
}
|
||||||
})()
|
})()
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true
|
cancelled = true
|
||||||
}
|
}
|
||||||
}, [canAccess, user?.id])
|
}, [canAccess, canAccessReports, user?.id])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const onChange = () => {
|
const onChange = () => { refresh() }
|
||||||
refresh()
|
|
||||||
}
|
|
||||||
window.addEventListener('shinkan:inbox-changed', onChange)
|
window.addEventListener('shinkan:inbox-changed', onChange)
|
||||||
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
|
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
|
||||||
}, [refresh])
|
}, [refresh])
|
||||||
|
|
@ -70,10 +96,14 @@ export function OrgInboxProvider({ user, children }) {
|
||||||
() => ({
|
() => ({
|
||||||
inboxJoinRequests: items,
|
inboxJoinRequests: items,
|
||||||
inboxCount: items.length,
|
inboxCount: items.length,
|
||||||
|
contentReports,
|
||||||
|
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
|
||||||
refreshOrgInbox: refresh,
|
refreshOrgInbox: refresh,
|
||||||
canAccessOrgInbox: canAccess,
|
canAccessOrgInbox: canAccess,
|
||||||
|
canAccessContentReports: canAccessReports,
|
||||||
|
isSuperadmin: user?.role === 'superadmin',
|
||||||
}),
|
}),
|
||||||
[items, refresh, canAccess]
|
[items, contentReports, refresh, canAccess, canAccessReports, user?.role]
|
||||||
)
|
)
|
||||||
|
|
||||||
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>
|
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,33 @@ const CLUB_ROLE_OPTIONS = [
|
||||||
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
|
{ code: 'content_editor', label: 'Inhalte bearbeiten' },
|
||||||
]
|
]
|
||||||
|
|
||||||
|
const REASON_LABELS = {
|
||||||
|
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',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_LABELS = {
|
||||||
|
submitted: 'Eingegangen',
|
||||||
|
under_review: 'In Bearbeitung',
|
||||||
|
resolved_no_action: 'Abgeschlossen (kein Handlungsbedarf)',
|
||||||
|
resolved_legal_hold: 'Abgeschlossen (Legal Hold)',
|
||||||
|
rejected_invalid: 'Abgewiesen (ungültig)',
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
submitted: 'var(--accent)',
|
||||||
|
under_review: '#e8960a',
|
||||||
|
resolved_no_action: 'var(--text3)',
|
||||||
|
resolved_legal_hold: 'var(--danger)',
|
||||||
|
rejected_invalid: 'var(--text3)',
|
||||||
|
}
|
||||||
|
|
||||||
function formatWhen(iso) {
|
function formatWhen(iso) {
|
||||||
if (!iso) return ''
|
if (!iso) return ''
|
||||||
const s = String(iso)
|
const s = String(iso)
|
||||||
|
|
@ -19,13 +46,292 @@ function formatWhen(iso) {
|
||||||
return time ? `${d} · ${time}` : d
|
return time ? `${d} · ${time}` : d
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function PriorityBadge({ priority }) {
|
||||||
|
if (priority !== 'high') return null
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
background: 'var(--danger)',
|
||||||
|
color: '#fff',
|
||||||
|
borderRadius: '4px',
|
||||||
|
padding: '2px 7px',
|
||||||
|
fontSize: '0.72rem',
|
||||||
|
fontWeight: 600,
|
||||||
|
marginLeft: '0.4rem',
|
||||||
|
verticalAlign: 'middle',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
DRINGEND
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin }) {
|
||||||
|
const [resolutionNote, setResolutionNote] = useState('')
|
||||||
|
const [legalHoldNote, setLegalHoldNote] = useState('')
|
||||||
|
const [saving, setSaving] = useState(false)
|
||||||
|
const [error, setError] = useState(null)
|
||||||
|
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
|
||||||
|
|
||||||
|
const isOpen = report.status === 'submitted' || report.status === 'under_review'
|
||||||
|
|
||||||
|
async function handleStatus(status) {
|
||||||
|
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
|
||||||
|
setError('Bitte eine Begründung eingeben.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.patchContentReport(report.id, {
|
||||||
|
status,
|
||||||
|
resolution_note: resolutionNote.trim() || undefined,
|
||||||
|
})
|
||||||
|
notifyOrgInboxChanged()
|
||||||
|
onRefresh()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleUnderReview() {
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.patchContentReport(report.id, { status: 'under_review' })
|
||||||
|
notifyOrgInboxChanged()
|
||||||
|
onRefresh()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleLegalHold() {
|
||||||
|
if (!legalHoldNote.trim()) {
|
||||||
|
setError('Begründung für Legal Hold erforderlich.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
setSaving(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
await api.setLegalHoldFromReport(report.id, { reason_note: legalHoldNote.trim() })
|
||||||
|
notifyOrgInboxChanged()
|
||||||
|
onRefresh()
|
||||||
|
onClose()
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.message || String(err))
|
||||||
|
} finally {
|
||||||
|
setSaving(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
position: 'fixed',
|
||||||
|
top: 0,
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
background: 'rgba(0,0,0,0.55)',
|
||||||
|
display: 'flex',
|
||||||
|
alignItems: 'flex-start',
|
||||||
|
justifyContent: 'center',
|
||||||
|
zIndex: 1100,
|
||||||
|
padding: '1rem',
|
||||||
|
overflowY: 'auto',
|
||||||
|
}}
|
||||||
|
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
style={{
|
||||||
|
background: 'var(--surface)',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1.5rem',
|
||||||
|
maxWidth: '560px',
|
||||||
|
width: '100%',
|
||||||
|
marginTop: '2rem',
|
||||||
|
marginBottom: '2rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1rem' }}>
|
||||||
|
<h2 style={{ margin: 0 }}>
|
||||||
|
Meldung #{report.id}
|
||||||
|
<PriorityBadge priority={report.priority} />
|
||||||
|
</h2>
|
||||||
|
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}>✕</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem', marginBottom: '1.2rem' }}>
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Status</span>
|
||||||
|
<div style={{ fontWeight: 600, color: STATUS_COLORS[report.status] }}>
|
||||||
|
{STATUS_LABELS[report.status] || report.status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Ziel</span>
|
||||||
|
<div>
|
||||||
|
{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{report.target_id}
|
||||||
|
{report.target_name ? ` – ${report.target_name}` : ''}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldegrund</span>
|
||||||
|
<div>{REASON_LABELS[report.report_reason] || report.report_reason}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Beschreibung</span>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{report.report_description}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Meldende Person</span>
|
||||||
|
<div>
|
||||||
|
{report.reporter_name}
|
||||||
|
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
|
||||||
|
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Eingegangen</span>
|
||||||
|
<div>{formatWhen(report.submitted_at || report.created_at)}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{report.resolution_note && (
|
||||||
|
<div>
|
||||||
|
<span className="muted" style={{ fontSize: '0.8rem' }}>Begründung</span>
|
||||||
|
<div style={{ whiteSpace: 'pre-wrap' }}>{report.resolution_note}</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{report.legal_hold_active && (
|
||||||
|
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.6rem 0.8rem' }}>
|
||||||
|
<span style={{ color: 'var(--danger)', fontWeight: 600 }}>Legal Hold aktiv</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
|
{report.status === 'submitted' && (
|
||||||
|
<button type="button" className="btn btn-primary" onClick={handleUnderReview} disabled={saving}>
|
||||||
|
In Bearbeitung nehmen
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label">Begründung (erforderlich für Abschluss/Abweisung)</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={resolutionNote}
|
||||||
|
onChange={(e) => setResolutionNote(e.target.value)}
|
||||||
|
placeholder="Kurze Begründung der Entscheidung…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleStatus('resolved_no_action')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Kein Handlungsbedarf
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={() => handleStatus('rejected_invalid')}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Meldung abweisen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isSuperadmin && !showLegalHoldForm && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ background: 'rgba(216,90,48,0.12)', color: 'var(--danger)', border: '1px solid var(--danger)' }}
|
||||||
|
onClick={() => setShowLegalHoldForm(true)}
|
||||||
|
>
|
||||||
|
Legal Hold setzen (Superadmin)
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isSuperadmin && showLegalHoldForm && (
|
||||||
|
<div style={{ border: '1px solid var(--danger)', borderRadius: '8px', padding: '0.75rem' }}>
|
||||||
|
<div className="form-row">
|
||||||
|
<label className="form-label" style={{ color: 'var(--danger)' }}>Begründung Legal Hold *</label>
|
||||||
|
<textarea
|
||||||
|
className="form-input"
|
||||||
|
rows={2}
|
||||||
|
value={legalHoldNote}
|
||||||
|
onChange={(e) => setLegalHoldNote(e.target.value)}
|
||||||
|
placeholder="Rechtliche Begründung für den Legal Hold…"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '0.5rem' }}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn"
|
||||||
|
style={{ background: 'var(--danger)', color: '#fff', flex: 1 }}
|
||||||
|
onClick={handleLegalHold}
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
Legal Hold bestätigen
|
||||||
|
</button>
|
||||||
|
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>
|
||||||
|
Abbrechen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!isOpen && (
|
||||||
|
<p className="muted" style={{ margin: 0, fontSize: '0.9rem' }}>
|
||||||
|
Diese Meldung ist bereits abgeschlossen.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
export default function InboxPage() {
|
export default function InboxPage() {
|
||||||
const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox()
|
const {
|
||||||
|
canAccessOrgInbox,
|
||||||
|
canAccessContentReports,
|
||||||
|
isSuperadmin,
|
||||||
|
refreshOrgInbox,
|
||||||
|
inboxJoinRequests,
|
||||||
|
contentReports,
|
||||||
|
contentReportCount,
|
||||||
|
} = useOrgInbox()
|
||||||
const [loading, setLoading] = useState(true)
|
const [loading, setLoading] = useState(true)
|
||||||
const [acceptModal, setAcceptModal] = useState(null)
|
const [acceptModal, setAcceptModal] = useState(null)
|
||||||
|
const [reportModal, setReportModal] = useState(null)
|
||||||
|
|
||||||
const load = useCallback(async () => {
|
const load = useCallback(async () => {
|
||||||
if (!canAccessOrgInbox) {
|
if (!canAccessOrgInbox && !canAccessContentReports) {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
@ -35,13 +341,13 @@ export default function InboxPage() {
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false)
|
setLoading(false)
|
||||||
}
|
}
|
||||||
}, [canAccessOrgInbox, refreshOrgInbox])
|
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
load()
|
load()
|
||||||
}, [load])
|
}, [load])
|
||||||
|
|
||||||
if (!canAccessOrgInbox) {
|
if (!canAccessOrgInbox && !canAccessContentReports) {
|
||||||
return (
|
return (
|
||||||
<div className="app-page">
|
<div className="app-page">
|
||||||
<h1 className="page-title">Posteingang</h1>
|
<h1 className="page-title">Posteingang</h1>
|
||||||
|
|
@ -61,7 +367,7 @@ export default function InboxPage() {
|
||||||
Posteingang
|
Posteingang
|
||||||
</h1>
|
</h1>
|
||||||
<p className="muted" style={{ marginTop: 0 }}>
|
<p className="muted" style={{ marginTop: 0 }}>
|
||||||
Offene Beitrittsanträge zu Vereinen, für die du zuständig bist.
|
Beitrittsanträge und Inhaltsmeldungen für deine Zuständigkeitsbereiche.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
|
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
|
||||||
|
|
@ -73,68 +379,168 @@ export default function InboxPage() {
|
||||||
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
||||||
<div className="spinner" />
|
<div className="spinner" />
|
||||||
</div>
|
</div>
|
||||||
) : inboxJoinRequests.length === 0 ? (
|
|
||||||
<div className="card" style={{ padding: '1.25rem' }}>
|
|
||||||
<p style={{ margin: 0 }} className="muted">
|
|
||||||
Keine offenen Beitrittsanträge.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
) : (
|
||||||
<div className="inbox-page__list">
|
<>
|
||||||
{inboxJoinRequests.map((req) => (
|
{/* Abschnitt 1: Beitrittsanträge */}
|
||||||
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
|
{canAccessOrgInbox && (
|
||||||
<div className="inbox-request-card__main">
|
<section style={{ marginBottom: '2rem' }}>
|
||||||
<div className="inbox-request-card__club">
|
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
|
||||||
{req.club_name || 'Verein'}
|
Beitrittsanträge
|
||||||
{req.club_abbreviation ? (
|
{inboxJoinRequests.length > 0 && (
|
||||||
<span className="muted" style={{ marginLeft: '0.35rem' }}>
|
<span
|
||||||
({req.club_abbreviation})
|
style={{
|
||||||
</span>
|
background: 'var(--accent)',
|
||||||
) : null}
|
color: '#fff',
|
||||||
|
borderRadius: '12px',
|
||||||
|
padding: '1px 8px',
|
||||||
|
fontSize: '0.75rem',
|
||||||
|
marginLeft: '0.5rem',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{inboxJoinRequests.length}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
{inboxJoinRequests.length === 0 ? (
|
||||||
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
|
<p style={{ margin: 0 }} className="muted">Keine offenen Beitrittsanträge.</p>
|
||||||
</div>
|
</div>
|
||||||
<strong className="inbox-request-card__applicant">
|
) : (
|
||||||
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
|
<div className="inbox-page__list">
|
||||||
</strong>
|
{inboxJoinRequests.map((req) => (
|
||||||
<div className="muted inbox-request-card__meta">
|
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
|
||||||
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
|
<div className="inbox-request-card__main">
|
||||||
|
<div className="inbox-request-card__club">
|
||||||
|
{req.club_name || 'Verein'}
|
||||||
|
{req.club_abbreviation ? (
|
||||||
|
<span className="muted" style={{ marginLeft: '0.35rem' }}>
|
||||||
|
({req.club_abbreviation})
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
<strong className="inbox-request-card__applicant">
|
||||||
|
{req.applicant_name || req.applicant_email || 'Bewerber/in'}
|
||||||
|
</strong>
|
||||||
|
<div className="muted inbox-request-card__meta">
|
||||||
|
{req.applicant_email} · Profil #{req.profile_id} · {formatWhen(req.created_at)}
|
||||||
|
</div>
|
||||||
|
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="inbox-request-card__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() =>
|
||||||
|
setAcceptModal({
|
||||||
|
id: req.id,
|
||||||
|
club_id: req.club_id,
|
||||||
|
label: req.applicant_name || req.applicant_email,
|
||||||
|
roles: ['trainer'],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Annehmen
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="btn btn-secondary"
|
||||||
|
onClick={async () => {
|
||||||
|
if (!confirm('Antrag ablehnen?')) return
|
||||||
|
try {
|
||||||
|
await api.rejectClubJoinRequest(req.club_id, req.id)
|
||||||
|
notifyOrgInboxChanged()
|
||||||
|
await load()
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message || String(err))
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Ablehnen
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
{req.message ? <p className="inbox-request-card__message">{req.message}</p> : null}
|
)}
|
||||||
</div>
|
</section>
|
||||||
<div className="inbox-request-card__actions">
|
)}
|
||||||
<button
|
|
||||||
type="button"
|
{/* Abschnitt 2: Inhaltsmeldungen (nur Plattform-Admins) */}
|
||||||
className="btn btn-primary"
|
{canAccessContentReports && (
|
||||||
onClick={() =>
|
<section>
|
||||||
setAcceptModal({
|
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
|
||||||
id: req.id,
|
Inhaltsmeldungen
|
||||||
club_id: req.club_id,
|
{contentReportCount > 0 && (
|
||||||
label: req.applicant_name || req.applicant_email,
|
<span
|
||||||
roles: ['trainer'],
|
style={{
|
||||||
})
|
background: 'var(--danger)',
|
||||||
}
|
color: '#fff',
|
||||||
>
|
borderRadius: '12px',
|
||||||
Annehmen
|
padding: '1px 8px',
|
||||||
</button>
|
fontSize: '0.75rem',
|
||||||
<button
|
marginLeft: '0.5rem',
|
||||||
type="button"
|
}}
|
||||||
className="btn btn-secondary"
|
>
|
||||||
onClick={async () => {
|
{contentReportCount} neu
|
||||||
if (!confirm('Antrag ablehnen?')) return
|
</span>
|
||||||
try {
|
)}
|
||||||
await api.rejectClubJoinRequest(req.club_id, req.id)
|
</h2>
|
||||||
notifyOrgInboxChanged()
|
|
||||||
await load()
|
{contentReports.length === 0 ? (
|
||||||
} catch (err) {
|
<div className="card" style={{ padding: '1.25rem' }}>
|
||||||
alert(err.message || String(err))
|
<p style={{ margin: 0 }} className="muted">Keine Inhaltsmeldungen.</p>
|
||||||
}
|
</div>
|
||||||
}}
|
) : (
|
||||||
>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||||
Ablehnen
|
{contentReports.map((rep) => (
|
||||||
</button>
|
<div
|
||||||
</div>
|
key={rep.id}
|
||||||
</div>
|
className="card"
|
||||||
))}
|
style={{
|
||||||
</div>
|
padding: '1rem 1.25rem',
|
||||||
|
cursor: 'pointer',
|
||||||
|
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
|
||||||
|
}}
|
||||||
|
onClick={() => setReportModal(rep)}
|
||||||
|
>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', gap: '0.5rem' }}>
|
||||||
|
<div>
|
||||||
|
<span style={{ fontWeight: 600 }}>
|
||||||
|
Meldung #{rep.id}
|
||||||
|
<PriorityBadge priority={rep.priority} />
|
||||||
|
</span>
|
||||||
|
<span className="muted" style={{ marginLeft: '0.75rem', fontSize: '0.85rem' }}>
|
||||||
|
{rep.target_type === 'media_asset' ? 'Medium' : 'Übung'} #{rep.target_id}
|
||||||
|
{rep.target_name ? ` – ${rep.target_name}` : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
style={{
|
||||||
|
fontSize: '0.78rem',
|
||||||
|
fontWeight: 500,
|
||||||
|
color: STATUS_COLORS[rep.status],
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{STATUS_LABELS[rep.status] || rep.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="muted" style={{ fontSize: '0.85rem', marginTop: '0.3rem' }}>
|
||||||
|
{REASON_LABELS[rep.report_reason] || rep.report_reason}
|
||||||
|
{' · '}
|
||||||
|
{rep.reporter_name}
|
||||||
|
{rep.reporter_profile_id ? ` (Profil #${rep.reporter_profile_id})` : ' (anonym)'}
|
||||||
|
{' · '}
|
||||||
|
{formatWhen(rep.submitted_at || rep.created_at)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{acceptModal && (
|
{acceptModal && (
|
||||||
|
|
@ -217,6 +623,15 @@ export default function InboxPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{reportModal && (
|
||||||
|
<ReportDetailModal
|
||||||
|
report={reportModal}
|
||||||
|
isSuperadmin={isSuperadmin}
|
||||||
|
onClose={() => setReportModal(null)}
|
||||||
|
onRefresh={load}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1592,6 +1592,18 @@ export const api = {
|
||||||
request(`/api/admin/legal-documents/${id}/copy-as-draft`, { method: 'POST' }),
|
request(`/api/admin/legal-documents/${id}/copy-as-draft`, { method: 'POST' }),
|
||||||
getLegalDocumentAudit: (id) =>
|
getLegalDocumentAudit: (id) =>
|
||||||
request(`/api/admin/legal-documents/${id}/audit`),
|
request(`/api/admin/legal-documents/${id}/audit`),
|
||||||
|
|
||||||
|
// P-13: Content-Melde-Backend
|
||||||
|
submitContentReport: (body) =>
|
||||||
|
request('/api/content-reports', { method: 'POST', body: JSON.stringify(body) }),
|
||||||
|
getInboxContentReports: () =>
|
||||||
|
request('/api/me/inbox/content-reports'),
|
||||||
|
getContentReport: (id) =>
|
||||||
|
request(`/api/content-reports/${id}`),
|
||||||
|
patchContentReport: (id, body) =>
|
||||||
|
request(`/api/content-reports/${id}`, { method: 'PATCH', body: JSON.stringify(body) }),
|
||||||
|
setLegalHoldFromReport: (id, body) =>
|
||||||
|
request(`/api/content-reports/${id}/legal-hold`, { method: 'POST', body: JSON.stringify(body) }),
|
||||||
}
|
}
|
||||||
|
|
||||||
export default api
|
export default api
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
// Shinkan Jinkendo Frontend Version
|
// Shinkan Jinkendo Frontend Version
|
||||||
|
|
||||||
export const APP_VERSION = "0.8.86"
|
export const APP_VERSION = "0.8.87"
|
||||||
export const BUILD_DATE = "2026-05-11"
|
export const BUILD_DATE = "2026-05-11"
|
||||||
|
|
||||||
export const PAGE_VERSIONS = {
|
export const PAGE_VERSIONS = {
|
||||||
|
|
@ -25,4 +25,5 @@ export const PAGE_VERSIONS = {
|
||||||
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
|
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
|
||||||
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
|
||||||
|
InboxPage: "2.0.0", // P-13: Inhaltsmeldungen-Abschnitt integriert
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user