Merge pull request 'DGSVO Compliance update 1' (#30) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 39s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 12s
Test Suite / playwright-tests (push) Successful in 57s

Reviewed-on: #30
This commit is contained in:
Lars 2026-05-12 06:34:14 +02:00
commit 4c974620d8
73 changed files with 12731 additions and 403 deletions

View File

@ -82,8 +82,8 @@ app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
)
@ -193,7 +193,7 @@ def read_root():
return out
# 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
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(profiles.router)
@ -205,6 +205,8 @@ app.include_router(club_join_requests.router)
app.include_router(admin_users.router)
app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(media_assets.admin_rights_router)
app.include_router(media_assets.admin_legal_hold_router)
app.include_router(skills.router)
app.include_router(training_planning.router)
app.include_router(training_framework_programs.router)
@ -213,6 +215,8 @@ app.include_router(maturity_models.router)
app.include_router(matrix_stack_bundle.router)
app.include_router(import_wiki.router)
app.include_router(import_wiki_admin.router)
app.include_router(legal_documents.router)
app.include_router(content_reports.router)
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).

247
backend/media_legal_hold.py Normal file
View File

@ -0,0 +1,247 @@
"""P-11: Legal-Hold-Services fuer Medien-Assets.
Sofortsperrung bei Rechtsverletzungen (Compliance-Paket P-11).
Klare Trennung:
- P-06: Rechteerklaerungs-/Deklarationsstatus (rights_status='declared'/'legacy_unreviewed')
- P-11: Legal-Hold-Sperre (legal_hold_active + Metadaten)
- P-03: Normaler Papierkorb-Lifecycle (lifecycle_state)
- P-13: Meldeverfahren (spaeter; nutzt denselben set_legal_hold-Service)
Berechtigungen:
- Setzen und Aufheben: ausschliesslich Superadmin.
- Lesezugriff auf Legal-Hold-Status: Superadmin und Plattform-Admin.
"""
from __future__ import annotations
from typing import Any, Optional
from fastapi import HTTPException
from club_tenancy import is_superadmin
from media_rights import write_audit_log_entry
LEGAL_HOLD_REASON_CODES = {
"rights_dispute",
"consent_withdrawn",
"privacy_complaint",
"copyright_complaint",
"youth_protection",
"illegal_content",
"other",
}
def assert_superadmin_for_legal_hold(role: Optional[str]) -> None:
if not is_superadmin(role):
raise HTTPException(
status_code=403,
detail="Legal-Hold-Aktionen sind nur fuer Superadmins verfuegbar.",
)
def is_media_available_for_normal_use(asset: dict) -> bool:
"""True wenn das Medium fuer normale Nutzerpfade verfuegbar ist.
Ein Medium ist NICHT verfuegbar wenn legal_hold_active = True,
unabhaengig vom lifecycle_state.
"""
return not bool(asset.get("legal_hold_active"))
def assert_not_under_legal_hold(asset: dict) -> None:
"""Wirft HTTPException 403 wenn das Medium unter Legal Hold steht."""
if bool(asset.get("legal_hold_active")):
raise HTTPException(
status_code=403,
detail={
"code": "LEGAL_HOLD_ACTIVE",
"message": (
"Dieses Medium ist durch einen Administrator sofort gesperrt und "
"kann nicht verwendet werden."
),
"asset_id": asset.get("id"),
},
)
def set_legal_hold(
cur: Any,
conn: Any,
asset_id: int,
acting_profile_id: int,
reason_code: str,
reason_note: Optional[str],
) -> dict:
"""Setzt Legal Hold auf ein Medium. Nur Superadmin.
Wirkung:
- legal_hold_active = TRUE
- legal_hold_reason_code, legal_hold_reason_note, legal_hold_set_by_profile_id, legal_hold_set_at
- rights_status = 'blocked' (Spiegel fuer schnelle Checks)
- Audit-Log-Eintrag 'legal_hold_set'
Gibt das aktualisierte Asset-Dict zurueck.
"""
if reason_code not in LEGAL_HOLD_REASON_CODES:
raise HTTPException(
status_code=400,
detail=f"Ungueltiger reason_code. Erlaubt: {sorted(LEGAL_HOLD_REASON_CODES)}",
)
if not reason_note or not reason_note.strip():
raise HTTPException(
status_code=400,
detail="reason_note (Begruendung) ist Pflicht beim Setzen eines Legal Holds.",
)
cur.execute(
"""SELECT id, legal_hold_active, rights_status, lifecycle_state, visibility
FROM media_assets WHERE id = %s""",
(asset_id,),
)
from db import r2d
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
if bool(asset.get("legal_hold_active")):
raise HTTPException(
status_code=409,
detail="Dieses Medium befindet sich bereits unter Legal Hold.",
)
old_rights_status = asset.get("rights_status") or "legacy_unreviewed"
cur.execute(
"""UPDATE media_assets
SET legal_hold_active = TRUE,
legal_hold_reason_code = %s,
legal_hold_reason_note = %s,
legal_hold_set_by_profile_id = %s,
legal_hold_set_at = NOW(),
legal_hold_released_by_profile_id = NULL,
legal_hold_released_at = NULL,
legal_hold_release_note = NULL,
rights_status = 'blocked',
updated_at = NOW()
WHERE id = %s
RETURNING id, legal_hold_active, legal_hold_reason_code, legal_hold_reason_note,
legal_hold_set_by_profile_id, legal_hold_set_at, rights_status,
lifecycle_state, visibility""",
(reason_code, reason_note.strip()[:2000], acting_profile_id, asset_id),
)
updated_row = cur.fetchone()
updated = r2d(updated_row)
write_audit_log_entry(
cur,
asset_id=asset_id,
acting_profile_id=acting_profile_id,
event_type="legal_hold_set",
old_values={"rights_status": old_rights_status, "legal_hold_active": False},
new_values={
"rights_status": "blocked",
"legal_hold_active": True,
"reason_code": reason_code,
"reason_note": reason_note.strip()[:2000] if reason_note else None,
},
)
conn.commit()
return updated
def release_legal_hold(
cur: Any,
conn: Any,
asset_id: int,
acting_profile_id: int,
release_note: Optional[str],
) -> dict:
"""Hebt Legal Hold auf. Nur Superadmin.
Wirkung:
- legal_hold_active = FALSE
- legal_hold_released_by_profile_id, legal_hold_released_at, legal_hold_release_note
- rights_status: zurueck auf 'declared' wenn vorher declared war, sonst 'legacy_unreviewed'
(Entscheidungslogik: war vor dem Hold ein Deklarations-Eintrag vorhanden?)
- Audit-Log-Eintrag 'legal_hold_released'
Gibt das aktualisierte Asset-Dict zurueck.
"""
if not release_note or not release_note.strip():
raise HTTPException(
status_code=400,
detail="release_note (Freigabe-Begruendung) ist Pflicht.",
)
cur.execute(
"""SELECT id, legal_hold_active, rights_status, lifecycle_state, visibility,
legal_hold_reason_code, legal_hold_reason_note
FROM media_assets WHERE id = %s""",
(asset_id,),
)
from db import r2d
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
if not bool(asset.get("legal_hold_active")):
raise HTTPException(
status_code=409,
detail="Dieses Medium befindet sich nicht unter Legal Hold.",
)
# Bestimme den rights_status nach Freigabe:
# Gibt es eine gueltige Deklaration? → 'declared', sonst 'legacy_unreviewed'
cur.execute(
"""SELECT COUNT(*) AS cnt FROM media_asset_rights_declarations
WHERE media_asset_id = %s
AND action_type NOT IN ('correction')
AND rights_holder_confirmed = TRUE""",
(asset_id,),
)
decl_row = cur.fetchone()
decl_count = int(decl_row[0] if not hasattr(decl_row, "keys") else decl_row["cnt"])
restored_rights_status = "declared" if decl_count > 0 else "legacy_unreviewed"
cur.execute(
"""UPDATE media_assets
SET legal_hold_active = FALSE,
legal_hold_released_by_profile_id = %s,
legal_hold_released_at = NOW(),
legal_hold_release_note = %s,
rights_status = %s,
updated_at = NOW()
WHERE id = %s
RETURNING id, legal_hold_active, rights_status, lifecycle_state, visibility,
legal_hold_reason_code, legal_hold_reason_note,
legal_hold_set_by_profile_id, legal_hold_set_at,
legal_hold_released_by_profile_id, legal_hold_released_at,
legal_hold_release_note""",
(acting_profile_id, release_note.strip()[:2000], restored_rights_status, asset_id),
)
updated_row = cur.fetchone()
updated = r2d(updated_row)
write_audit_log_entry(
cur,
asset_id=asset_id,
acting_profile_id=acting_profile_id,
event_type="legal_hold_released",
old_values={
"rights_status": "blocked",
"legal_hold_active": True,
"reason_code": asset.get("legal_hold_reason_code"),
},
new_values={
"rights_status": restored_rights_status,
"legal_hold_active": False,
"release_note": release_note.strip()[:2000] if release_note else None,
},
)
conn.commit()
return updated

View File

@ -21,7 +21,8 @@ LC_TRASH_SOFT = "trash_soft"
LC_TRASH_HIDDEN = "trash_hidden"
SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30")))
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90")))
# P-03b: Default gemaess fachlichem Loeschkonzept (Audit 2026-05-09): 30+30 Tage.
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30")))
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
@ -278,6 +279,10 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
"""
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS trash_hidden;
trash_hidden mit purge_after_at in der Vergangenheit purge.
P-11: Medien unter aktivem Legal Hold werden NICHT gerpurged (legal_hold_active = TRUE).
Die Retention verschiebt sie auch nicht automatisch von trash_soft nach trash_hidden
Legal-Hold-Status hat Vorrang vor dem Papierkorb-Lifecycle.
"""
cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
cur.execute(
@ -285,6 +290,7 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = NOW() + (%s * INTERVAL '1 day')
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)
RETURNING id""",
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft),
)
@ -293,7 +299,8 @@ def run_retention_pass(cur: Any, conn: Any) -> dict:
cur.execute(
"""SELECT id FROM media_assets
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()""",
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)""",
(LC_TRASH_HIDDEN,),
)
purge_ids = [r2d(r)["id"] for r in cur.fetchall()]

412
backend/media_rights.py Normal file
View File

@ -0,0 +1,412 @@
"""P-06: Zentrale Rechte-Policy fuer Medien-Uploads und Promotionen.
Konservative Erstannahmen (p06-v1-conservative):
- Alle Uploads (inkl. private) erfordern vollstaendige Erklaerung
- Personenfragen bei allen Sichtbarkeiten Pflicht zu beantworten
- Promotion zu hoeherem Niveau erfordert neue Erklaerung
- Altmedien ('legacy_unreviewed') duerfen nicht promoted werden
VORLAEUTIG: Juristische Validierung der Felder und Texte steht aus.
"""
from __future__ import annotations
import json as _json
from typing import Any, Optional
from fastapi import HTTPException
DECLARATION_VERSION = "p06-v1-conservative"
# Sichtbarkeits-Hierarchie: private(1) < club(2) < official(3)
VISIBILITY_LEVELS: dict[str, int] = {
"private": 1,
"club": 2,
"official": 3,
}
def visibility_level(vis: str) -> int:
return VISIBILITY_LEVELS.get((vis or "").strip().lower(), 0)
def rights_covers_target(declared_for: Optional[str], target_vis: str) -> bool:
"""True wenn die vorhandene Erklaerung die Ziel-Sichtbarkeit abdeckt."""
if not declared_for:
return False
return visibility_level(declared_for) >= visibility_level(target_vis)
# --------------------------------------------------------------------------
# Validierung einer eingehenden Erklaerung
# --------------------------------------------------------------------------
def validate_rights_declaration(decl: dict[str, Any], target_visibility: str) -> None:
"""Pruefen ob alle Pflichtfelder der konservativen Erstannahme vorliegen.
Wirft HTTPException 400 mit maschinenlesbarem code bei Verstoss.
Gilt fuer alle Sichtbarkeiten (private/club/official) identisch.
"""
# 1. rights_holder_confirmed ist immer Pflicht
if not decl.get("rights_holder_confirmed"):
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": (
"Bitte bestaetigen, dass du die erforderlichen Rechte an diesem Medium besitzt."
),
},
)
# 2. contains_identifiable_persons muss explizit beantwortet sein
if decl.get("contains_identifiable_persons") is None:
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": "Bitte angeben, ob erkennbare Personen abgebildet sind.",
},
)
# 3. Wenn Personen vorhanden: Einwilligung Pflicht
if decl.get("contains_identifiable_persons") is True:
if not decl.get("person_consent_confirmed"):
raise HTTPException(
status_code=400,
detail={
"code": "PERSON_CONSENT_REQUIRED",
"message": (
"Bitte bestaetigen, dass die Einwilligungen aller erkennbaren Personen vorliegen."
),
},
)
# 4. contains_minors muss explizit beantwortet sein
if decl.get("contains_minors") is None:
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": "Bitte angeben, ob Minderjaehrige abgebildet sind.",
},
)
# 5. Wenn Minderjaehrige: Elterneinwilligung Pflicht
if decl.get("contains_minors") is True:
if not decl.get("parental_consent_confirmed"):
raise HTTPException(
status_code=400,
detail={
"code": "PARENTAL_CONSENT_REQUIRED",
"message": (
"Bitte bestaetigen, dass die Einwilligungen der Sorgeberechtigten vorliegen."
),
},
)
# 6. contains_music muss explizit beantwortet sein
if decl.get("contains_music") is None:
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": "Bitte angeben, ob das Medium Musik enthaelt.",
},
)
# 7. Wenn Musik: Musikrechte Pflicht
if decl.get("contains_music") is True:
if not decl.get("music_rights_confirmed"):
raise HTTPException(
status_code=400,
detail={
"code": "MUSIC_RIGHTS_REQUIRED",
"message": (
"Bitte bestaetigen, dass die erforderlichen Musikrechte vorliegen."
),
},
)
# 8. contains_third_party_content muss explizit beantwortet sein
if decl.get("contains_third_party_content") is None:
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": "Bitte angeben, ob fremde geschuetzte Inhalte (Logos, Grafiken etc.) enthalten sind.",
},
)
# 9. Wenn Fremdmaterial: Rechte Pflicht
if decl.get("contains_third_party_content") is True:
if not decl.get("third_party_rights_confirmed"):
raise HTTPException(
status_code=400,
detail={
"code": "THIRD_PARTY_RIGHTS_REQUIRED",
"message": (
"Bitte bestaetigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen."
),
},
)
# --------------------------------------------------------------------------
# Pruefen ob vorhandene Erklaerung Zielsichtbarkeit abdeckt
# --------------------------------------------------------------------------
def check_rights_coverage(cur: Any, asset_id: int, target_visibility: str) -> str:
"""Status der Rechteabdeckung fuer ein Asset und eine Zielsichtbarkeit.
Returns:
'ok' - vorhandene Erklaerung reicht aus
'legacy' - Altmedium ohne Erklaerung (legacy_unreviewed)
'blocked' - durch Admin gesperrt
'no_declaration' - neues Medium ohne Erklaerung (sollte nicht vorkommen)
Hinweis: Eine P-06-Erklaerung beschreibt den Inhalt (Rechteinhaber, Personen, Musik etc.)
und ist sichtbarkeitsunabhaengig. rights_status='declared' gilt daher fuer alle
Sichtbarkeits-Stufen ohne Levelvergleich.
"""
cur.execute(
"SELECT rights_status FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
return "no_declaration"
rs = (row[0] if not hasattr(row, "keys") else row["rights_status"] or "").strip().lower()
if rs == "blocked":
return "blocked"
if rs == "legacy_unreviewed":
return "legacy"
if rs == "declared":
return "ok"
return "no_declaration"
def assert_rights_for_promotion(cur: Any, asset_id: int, target_visibility: str) -> None:
"""Wirft HTTPException wenn das Asset keine gueltige Erklaerung fuer target_visibility hat."""
status = check_rights_coverage(cur, asset_id, target_visibility)
if status == "ok":
return
if status == "legacy":
raise HTTPException(
status_code=400,
detail={
"code": "LEGACY_REDECLARATION_REQUIRED",
"message": (
"Dieses Medium wurde vor Einfuehrung der Einwilligungspflicht hochgeladen. "
"Bitte eine Rechterklaerung nachreichen, bevor die Sichtbarkeit erhoeht wird."
),
"asset_id": asset_id,
},
)
if status == "blocked":
raise HTTPException(
status_code=403,
detail={
"code": "RIGHTS_BLOCKED",
"message": "Dieses Medium ist durch einen Administrator gesperrt.",
"asset_id": asset_id,
},
)
# no_declaration (neues Medium ohne Erklaerung)
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_DECLARATION_REQUIRED",
"message": "Fuer dieses Medium liegt keine Rechterklaerung vor.",
"asset_id": asset_id,
},
)
def assert_rights_for_exercise_link(cur: Any, asset_id: int, exercise_visibility: str) -> None:
"""Pruefen ob das Asset in eine Uebung mit dieser Sichtbarkeit eingebunden werden darf."""
status = check_rights_coverage(cur, asset_id, exercise_visibility)
if status == "ok":
return
if status == "legacy" and exercise_visibility == "private":
# Altmedien duerfen in private Uebungen eingebunden bleiben (kein Upgrade-Risiko)
return
if status == "legacy":
raise HTTPException(
status_code=400,
detail={
"code": "LEGACY_REDECLARATION_REQUIRED",
"message": (
"Das gewahlte Archiv-Medium hat noch keine Rechterklaerung nach neuem Standard. "
"Bitte zuerst eine Erklaerung fuer dieses Medium abgeben."
),
"asset_id": asset_id,
},
)
if status == "insufficient":
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_SCOPE_INSUFFICIENT",
"message": (
f"Das Archiv-Medium hat keine Erklaerung fuer Sichtbarkeit '{exercise_visibility}'. "
"Bitte zuerst eine neue Erklaerung fuer dieses Medium abgeben."
),
"asset_id": asset_id,
},
)
if status == "blocked":
raise HTTPException(
status_code=403,
detail={
"code": "RIGHTS_BLOCKED",
"message": "Dieses Medium ist gesperrt und kann nicht verwendet werden.",
"asset_id": asset_id,
},
)
# --------------------------------------------------------------------------
# Declaration-Log schreiben + Schnellfelder aktualisieren
# --------------------------------------------------------------------------
def _clean_context(val: Any) -> Optional[str]:
"""Leere Strings → None, sonst auf 2000 Zeichen kuerzen."""
s = (val or "").strip()
return s[:2000] if s else None
def write_rights_declaration(
cur: Any,
asset_id: int,
profile_id: int,
action_type: str,
target_visibility: str,
decl: dict[str, Any],
) -> int:
"""Schreibt einen neuen Eintrag in media_asset_rights_declarations (append-only).
Returns: id des neuen Eintrags
"""
cur.execute(
"""INSERT INTO media_asset_rights_declarations (
media_asset_id, declared_by_profile_id, action_type, target_visibility,
declaration_version,
rights_holder_confirmed,
contains_identifiable_persons, person_consent_confirmed, person_consent_context,
contains_minors, parental_consent_confirmed, parental_consent_context,
contains_music, music_rights_confirmed, music_rights_context,
contains_third_party_content, third_party_rights_confirmed, third_party_rights_context
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
asset_id,
profile_id,
action_type,
target_visibility,
DECLARATION_VERSION,
bool(decl.get("rights_holder_confirmed")),
decl.get("contains_identifiable_persons"),
decl.get("person_consent_confirmed"),
_clean_context(decl.get("person_consent_context")),
decl.get("contains_minors"),
decl.get("parental_consent_confirmed"),
_clean_context(decl.get("parental_consent_context")),
decl.get("contains_music"),
decl.get("music_rights_confirmed"),
_clean_context(decl.get("music_rights_context")),
decl.get("contains_third_party_content"),
decl.get("third_party_rights_confirmed"),
_clean_context(decl.get("third_party_rights_context")),
),
)
row = cur.fetchone()
if hasattr(row, "keys"):
return int(row["id"])
return int(row[0])
def write_audit_log_entry(
cur: Any,
asset_id: int,
acting_profile_id: Optional[int],
event_type: str,
old_values: dict,
new_values: dict,
) -> None:
"""Schreibt einen Eintrag in media_asset_audit_log (append-only)."""
cur.execute(
"""INSERT INTO media_asset_audit_log
(media_asset_id, acting_profile_id, event_type, old_values, new_values)
VALUES (%s, %s, %s, %s, %s)""",
(
asset_id,
acting_profile_id,
event_type,
_json.dumps(old_values, default=str),
_json.dumps(new_values, default=str),
),
)
def write_rights_correction_declaration(
cur: Any,
asset_id: int,
profile_id: int,
target_visibility: str,
decl: dict[str, Any],
correction_note: Optional[str],
) -> int:
"""Schreibt eine Korrektur-Deklaration (action_type='correction', append-only)."""
cur.execute(
"""INSERT INTO media_asset_rights_declarations (
media_asset_id, declared_by_profile_id, action_type, target_visibility,
declaration_version,
rights_holder_confirmed,
contains_identifiable_persons, person_consent_confirmed, person_consent_context,
contains_minors, parental_consent_confirmed, parental_consent_context,
contains_music, music_rights_confirmed, music_rights_context,
contains_third_party_content, third_party_rights_confirmed, third_party_rights_context,
correction_note
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s)
RETURNING id""",
(
asset_id,
profile_id,
"correction",
target_visibility,
DECLARATION_VERSION,
bool(decl.get("rights_holder_confirmed")),
decl.get("contains_identifiable_persons"),
decl.get("person_consent_confirmed"),
_clean_context(decl.get("person_consent_context")),
decl.get("contains_minors"),
decl.get("parental_consent_confirmed"),
_clean_context(decl.get("parental_consent_context")),
decl.get("contains_music"),
decl.get("music_rights_confirmed"),
_clean_context(decl.get("music_rights_context")),
decl.get("contains_third_party_content"),
decl.get("third_party_rights_confirmed"),
_clean_context(decl.get("third_party_rights_context")),
_clean_context(correction_note),
),
)
row = cur.fetchone()
if hasattr(row, "keys"):
return int(row["id"])
return int(row[0])
def update_rights_quick_fields(cur: Any, asset_id: int, target_visibility: str) -> None:
"""Setzt die Schnellfelder in media_assets nach erfolgreicher Deklaration."""
cur.execute(
"""UPDATE media_assets
SET rights_status = 'declared',
rights_declared_for_visibility = %s,
rights_declared_at = NOW(),
updated_at = NOW()
WHERE id = %s""",
(target_visibility, asset_id),
)

View File

@ -0,0 +1,37 @@
-- Migration 047: Admin-konfigurierbare Rechtstexte
-- Tabellen: legal_documents (versioniert), legal_document_audit (Änderungslog)
-- document_type: impressum | privacy_policy | terms_of_use | media_policy
-- status: draft | published | archived
-- Partial unique index: nur genau ein published-Dokument pro document_type erlaubt.
CREATE TABLE IF NOT EXISTS legal_documents (
id SERIAL PRIMARY KEY,
document_type VARCHAR(50) NOT NULL
CHECK (document_type IN ('impressum', 'privacy_policy', 'terms_of_use', 'media_policy')),
version INT NOT NULL DEFAULT 1,
title VARCHAR(255) NOT NULL,
content_sections JSONB NOT NULL DEFAULT '[]',
status VARCHAR(20) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'archived')),
change_note TEXT,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
created_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
published_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
published_at TIMESTAMP
);
-- Sicherstellt: pro document_type maximal ein published-Datensatz
CREATE UNIQUE INDEX IF NOT EXISTS legal_documents_unique_published
ON legal_documents (document_type)
WHERE status = 'published';
CREATE TABLE IF NOT EXISTS legal_document_audit (
id SERIAL PRIMARY KEY,
legal_document_id INT NOT NULL REFERENCES legal_documents(id) ON DELETE CASCADE,
action VARCHAR(50) NOT NULL,
changed_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
change_note TEXT,
previous_status VARCHAR(20),
created_at TIMESTAMP DEFAULT NOW()
);

View File

@ -0,0 +1,75 @@
-- Migration 048: P-06 Upload-Einwilligungsdialog
-- Append-only Deklarations-Log + Schnellfelder in media_assets
-- Alle bestehenden Medien erhalten rights_status = 'legacy_unreviewed'
-- Deklarations-Log (append-only, wird nie geaendert oder geloescht)
CREATE TABLE IF NOT EXISTS media_asset_rights_declarations (
id SERIAL PRIMARY KEY,
media_asset_id INT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
declared_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
declared_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
-- Kontext der Erklaerung
action_type VARCHAR(50) NOT NULL
CHECK (action_type IN (
'upload', -- Erstupload
'promote_club', -- Promotion zu club
'promote_official', -- Promotion zu official
're_declaration', -- Freiwillige Nacherklaerung
'legacy_re_declaration' -- Altmedium: erste Erklaerung nachgereicht
)),
target_visibility VARCHAR(32) NOT NULL
CHECK (target_visibility IN ('private', 'club', 'official')),
-- Textversion der Erklaerung; 'p06-v1-conservative' = konservative Erstannahmen
-- VORLAEUTIG: Texte noch nicht juristisch geprueft
declaration_version VARCHAR(40) NOT NULL DEFAULT 'p06-v1-conservative',
-- Pflichtfeld (alle Sichtbarkeiten, alle Aktionen)
rights_holder_confirmed BOOLEAN NOT NULL,
-- Personen (konservative Annahme: immer abgefragt, auch bei 'private')
contains_identifiable_persons BOOLEAN,
person_consent_confirmed BOOLEAN, -- Pflicht wenn contains_identifiable_persons = true
-- Minderjaehrige
contains_minors BOOLEAN,
parental_consent_confirmed BOOLEAN, -- Pflicht wenn contains_minors = true
-- Drittmaterial
contains_music BOOLEAN,
music_rights_confirmed BOOLEAN, -- Pflicht wenn contains_music = true
contains_third_party_content BOOLEAN,
third_party_rights_confirmed BOOLEAN, -- Pflicht wenn contains_third_party_content = true
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS idx_mard_asset
ON media_asset_rights_declarations (media_asset_id);
CREATE INDEX IF NOT EXISTS idx_mard_profile
ON media_asset_rights_declarations (declared_by_profile_id);
CREATE INDEX IF NOT EXISTS idx_mard_action_type
ON media_asset_rights_declarations (action_type);
-- Schnellfelder in media_assets (kein Ersatz fuer den Log, nur fuer effiziente Abfragen)
ALTER TABLE media_assets
ADD COLUMN IF NOT EXISTS rights_status VARCHAR(32)
NOT NULL DEFAULT 'legacy_unreviewed'
CHECK (rights_status IN ('legacy_unreviewed', 'declared', 'blocked')),
ADD COLUMN IF NOT EXISTS rights_declared_for_visibility VARCHAR(32)
CHECK (rights_declared_for_visibility IN ('private', 'club', 'official')),
ADD COLUMN IF NOT EXISTS rights_declared_at TIMESTAMPTZ;
-- Bestehende Medien: explicit legacy_unreviewed setzen (redundant zum DEFAULT, zur Klarheit)
UPDATE media_assets
SET rights_status = 'legacy_unreviewed'
WHERE rights_status = 'legacy_unreviewed'; -- no-op, setzt Default explizit
COMMENT ON TABLE media_asset_rights_declarations IS
'P-06: Append-only Erklaerungslog fuer Upload-Einwilligungen. '
'Eintraege werden nie geaendert. Juristische Validierung der Felder und Texte steht aus.';
COMMENT ON COLUMN media_assets.rights_status IS
'P-06: legacy_unreviewed = Altbestand ohne P-06-Erklaerung; '
'declared = gueltige Erklaerung fuer rights_declared_for_visibility; '
'blocked = durch Admin gesperrt (P-11-Schnittstelle).';

View File

@ -0,0 +1,18 @@
-- Migration 049: P-06 Erweiterung Einwilligungskontext-Felder und Copyright im Upload-Dialog
-- Optionale Freitextfelder fuer den Kontext der Einwilligung (z.B. "Schriftliche Einwilligung
-- vom 2026-05-01 liegt vor") sowie copyright_notice direkt beim Upload erfassbar.
ALTER TABLE media_asset_rights_declarations
ADD COLUMN IF NOT EXISTS person_consent_context TEXT,
ADD COLUMN IF NOT EXISTS parental_consent_context TEXT,
ADD COLUMN IF NOT EXISTS music_rights_context TEXT,
ADD COLUMN IF NOT EXISTS third_party_rights_context TEXT;
COMMENT ON COLUMN media_asset_rights_declarations.person_consent_context IS
'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der abgebildeten Personen vor?';
COMMENT ON COLUMN media_asset_rights_declarations.parental_consent_context IS
'Optionaler Freitext: In welchem Zusammenhang liegt die Einwilligung der Sorgeberechtigten vor?';
COMMENT ON COLUMN media_asset_rights_declarations.music_rights_context IS
'Optionaler Freitext: Welche Lizenz / GEMA-Regelung liegt fuer die Musik vor?';
COMMENT ON COLUMN media_asset_rights_declarations.third_party_rights_context IS
'Optionaler Freitext: Auf welcher Grundlage duerfen die enthaltenen Fremdinhalte verwendet werden?';

View File

@ -0,0 +1,47 @@
-- Migration 050: Medien-Volljournal Audit-Log für alle Änderungen + Korrektur-Deklarationen
-- Vollständiger Audit-Log: Sichtbarkeitsänderungen, Copyright, Metadaten, Lifecycle
CREATE TABLE IF NOT EXISTS media_asset_audit_log (
id SERIAL PRIMARY KEY,
media_asset_id INT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
acting_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL
CHECK (event_type IN (
'visibility_change',
'copyright_change',
'metadata_change',
'lifecycle_change'
)),
old_values JSONB,
new_values JSONB
);
CREATE INDEX IF NOT EXISTS idx_maal_asset ON media_asset_audit_log (media_asset_id);
CREATE INDEX IF NOT EXISTS idx_maal_asset_occurred ON media_asset_audit_log (media_asset_id, occurred_at);
COMMENT ON TABLE media_asset_audit_log IS
'Append-only Protokoll aller Aenderungen an Medien-Assets (Sichtbarkeit, Copyright, Metadaten, Lifecycle). '
'Wird nie aktualisiert oder geloescht (ausser ON DELETE CASCADE des Assets).';
-- Korrektur-Notiz für nachtraegliche Deklarations-Korrekturen
ALTER TABLE media_asset_rights_declarations
ADD COLUMN IF NOT EXISTS correction_note TEXT;
COMMENT ON COLUMN media_asset_rights_declarations.correction_note IS
'Optionale Begruendung fuer action_type=correction: Warum wurde die Erklaerung korrigiert?';
-- ''correction'' action_type hinzufuegen (bestehende CHECK-Constraint ersetzen)
ALTER TABLE media_asset_rights_declarations
DROP CONSTRAINT IF EXISTS media_asset_rights_declarations_action_type_check;
ALTER TABLE media_asset_rights_declarations
ADD CONSTRAINT media_asset_rights_declarations_action_type_check
CHECK (action_type IN (
'upload',
'promote_club',
'promote_official',
're_declaration',
'legacy_re_declaration',
'correction'
));

View File

@ -0,0 +1,73 @@
-- Migration 051: P-11 Legal-Hold Lifecycle-Status
-- Sofortsperrung fuer rechtlich problematische Medien (Compliance-Paket P-11)
--
-- Architekturentscheidung:
-- rights_status='blocked' bleibt als Spiegel-Schnellstatus (P-06-Kompatibilitaet).
-- Primaere Wahrheit: legal_hold_active + dedizierte Metadaten-Felder in media_assets.
-- Dies ermoeglicht klare Trennung: P-06 Deklarationsstatus / P-11 Legal Hold / P-03 Lifecycle.
-- P-13 kann spaeter denselben set_legal_hold-Service nutzen.
ALTER TABLE media_assets
ADD COLUMN IF NOT EXISTS legal_hold_active BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS legal_hold_reason_code VARCHAR(50)
CHECK (legal_hold_reason_code IN (
'rights_dispute',
'consent_withdrawn',
'privacy_complaint',
'copyright_complaint',
'youth_protection',
'illegal_content',
'other'
)),
ADD COLUMN IF NOT EXISTS legal_hold_reason_note TEXT,
ADD COLUMN IF NOT EXISTS legal_hold_set_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS legal_hold_set_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS legal_hold_released_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
ADD COLUMN IF NOT EXISTS legal_hold_released_at TIMESTAMPTZ,
ADD COLUMN IF NOT EXISTS legal_hold_release_note TEXT;
COMMENT ON COLUMN media_assets.legal_hold_active IS
'P-11: TRUE = Medium unter Legal Hold; sofortige Sperrung fuer alle normalen Nutzerpfade. '
'Retention-Job darf dieses Medium nicht purgen. '
'rights_status wird bei Aktivierung auf ''blocked'' gespiegelt.';
COMMENT ON COLUMN media_assets.legal_hold_reason_code IS
'P-11: Kategorie des Legal Holds. Pflicht beim Setzen.';
COMMENT ON COLUMN media_assets.legal_hold_reason_note IS
'P-11: Freitext-Begruendung fuer den Legal Hold.';
COMMENT ON COLUMN media_assets.legal_hold_set_by_profile_id IS
'P-11: Profil das den Legal Hold gesetzt hat (Superadmin).';
COMMENT ON COLUMN media_assets.legal_hold_set_at IS
'P-11: Zeitpunkt der Legal-Hold-Aktivierung.';
COMMENT ON COLUMN media_assets.legal_hold_released_by_profile_id IS
'P-11: Profil das den Legal Hold aufgehoben hat.';
COMMENT ON COLUMN media_assets.legal_hold_released_at IS
'P-11: Zeitpunkt der Legal-Hold-Freigabe.';
COMMENT ON COLUMN media_assets.legal_hold_release_note IS
'P-11: Begruendung fuer die Aufhebung des Legal Holds.';
-- Index fuer Admin-Liste aktiver Legal Holds
CREATE INDEX IF NOT EXISTS idx_media_assets_legal_hold_active
ON media_assets (legal_hold_active)
WHERE legal_hold_active = TRUE;
-- Neue event_types fuer media_asset_audit_log
ALTER TABLE media_asset_audit_log
DROP CONSTRAINT IF EXISTS media_asset_audit_log_event_type_check;
ALTER TABLE media_asset_audit_log
ADD CONSTRAINT media_asset_audit_log_event_type_check
CHECK (event_type IN (
'visibility_change',
'copyright_change',
'metadata_change',
'lifecycle_change',
'legal_hold_set',
'legal_hold_released'
));

View 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);

View File

@ -0,0 +1,21 @@
-- Migration 053: Audit-Log event_type-Constraint korrigieren + 'content_report_filed' hinzufuegen
--
-- Hintergrund: Die Tabelle media_asset_audit_log wurde per CREATE TABLE IF NOT EXISTS angelegt.
-- Da die Tabelle bereits existierte, wurde die CHECK-Constraint aus Migration 050 nie angewendet.
-- In der DB existieren Zeilen mit legal_hold_set und legal_hold_released (aus P-11).
-- Diese Migration setzt die Constraint erstmalig mit allen gueltigen Werten.
ALTER TABLE media_asset_audit_log
DROP CONSTRAINT IF EXISTS media_asset_audit_log_event_type_check;
ALTER TABLE media_asset_audit_log
ADD CONSTRAINT media_asset_audit_log_event_type_check
CHECK (event_type IN (
'visibility_change',
'copyright_change',
'metadata_change',
'lifecycle_change',
'legal_hold_set',
'legal_hold_released',
'content_report_filed'
));

View File

@ -26,7 +26,7 @@ class PasswordResetRequest(BaseModel):
class PasswordResetConfirm(BaseModel):
token: str
new_password: str
new_password: str = Field(min_length=8, max_length=128)
class ProfileCreate(BaseModel):
"""Nur für POST /api/profiles (Plattform-Admin): neues Nutzerprofil ohne Self-Registration."""

View File

@ -98,8 +98,8 @@ def change_pin(req: dict, session: dict=Depends(require_auth)):
"""Change PIN/password for current user."""
pid = session['profile_id']
new_pin = req.get('pin', '')
if len(new_pin) < 4:
raise HTTPException(400, "PIN/Passwort muss mind. 4 Zeichen haben")
if len(new_pin) < 8:
raise HTTPException(400, "Passwort muss mind. 8 Zeichen haben")
new_hash = hash_pin(new_pin)
with get_db() as conn:

View File

@ -0,0 +1,931 @@
"""
P-13: Content-Melde-Backend
Meldeverfahren fuer moeglicherweise rechtswidrige Inhalte (DSA-Grundlage, KRIT-03).
Architektur:
- Fachliche Daten in 'content_reports' Tabelle (Migration 052)
- Integration in bestehende Admin-Inbox (GET /api/me/inbox/content-reports)
- Keine separate Admin-Queue; die InboxPage.jsx zeigt beide Vorgangstypen
- P-11-Anschluss: Superadmin kann aus Meldung heraus Legal Hold setzen
Berechtigungen:
- Meldung einreichen: optional Auth (ohne Auth nur fuer official-Medien erlaubt)
- Liste/Detail: Plattform-Admin (alle Meldungen) oder Club-Admin (nur eigene Vereinsmedien)
- Status-Bearbeitung: Plattform-Admin
- Legal Hold aus Meldung: ausschliesslich Superadmin
E-Mail-Benachrichtigungen:
- Nach Eingang: Bestaetigung an Melder
- Nach Eingang: Benachrichtigung aller Plattform-Admins (best-effort, kein Fehler wenn SMTP fehlt)
Audit-Log (Migration 053):
- Bei media_asset-Meldungen: Eintrag in media_asset_audit_log (event_type='content_report_filed')
"""
from __future__ import annotations
import os
import re
from typing import Optional
from fastapi import APIRouter, Depends, Header, HTTPException
from pydantic import BaseModel, Field, field_validator
from club_tenancy import is_platform_admin, is_superadmin
from db import get_db, get_cursor, r2d
from media_legal_hold import (
LEGAL_HOLD_REASON_CODES,
assert_superadmin_for_legal_hold,
set_legal_hold,
)
from media_rights import write_audit_log_entry
from tenant_context import TenantContext, get_tenant_context
router = APIRouter(prefix="/api", tags=["content_reports"])
# Erlaubte Meldungsgruende
REPORT_REASONS = frozenset({
"copyright",
"image_rights",
"privacy",
"minors",
"illegal_content",
"youth_protection",
"offensive_content",
"other",
})
REASON_LABELS_DE = {
"copyright": "Urheberrecht",
"image_rights": "Bildrechte",
"privacy": "Datenschutz / Persönlichkeitsrecht",
"minors": "Minderjährige",
"illegal_content": "Rechtswidriger Inhalt",
"youth_protection": "Jugendschutz",
"offensive_content": "Beleidigender Inhalt",
"other": "Sonstiges",
}
# Gruende mit hoher Prioritaet (illegal, Minderjaehrigenschutz)
HIGH_PRIORITY_REASONS = frozenset({
"minors",
"illegal_content",
"youth_protection",
})
# Mapping: report_reason -> legal_hold reason_code
_REASON_TO_HOLD_CODE = {
"copyright": "copyright_complaint",
"image_rights": "rights_dispute",
"privacy": "privacy_complaint",
"minors": "youth_protection",
"illegal_content": "illegal_content",
"youth_protection": "youth_protection",
"offensive_content": "illegal_content",
"other": "other",
}
_EMAIL_RE = re.compile(r"^[^@\s]+@[^@\s]+\.[^@\s]+$")
# ─── Pydantic-Modelle ────────────────────────────────────────────────────────
class ContentReportCreate(BaseModel):
target_type: str = Field(default="media_asset")
target_id: int = Field(..., ge=1)
report_reason: str
report_description: str = Field(..., min_length=10, max_length=8000)
reporter_name: str = Field(..., min_length=1, max_length=200)
reporter_email: str = Field(..., min_length=5, max_length=200)
good_faith_confirmed: bool
@field_validator("target_type")
@classmethod
def validate_target_type(cls, v: str) -> str:
if v not in ("media_asset", "exercise"):
raise ValueError("target_type muss 'media_asset' oder 'exercise' sein")
return v
@field_validator("report_reason")
@classmethod
def validate_reason(cls, v: str) -> str:
if v not in REPORT_REASONS:
raise ValueError(f"Ungueltiger report_reason. Erlaubt: {sorted(REPORT_REASONS)}")
return v
@field_validator("reporter_email")
@classmethod
def validate_email(cls, v: str) -> str:
if not _EMAIL_RE.match(v.strip()):
raise ValueError("Ungueltige E-Mail-Adresse")
return v.strip().lower()
@field_validator("reporter_name")
@classmethod
def validate_name(cls, v: str) -> str:
v = v.strip()
if not v:
raise ValueError("Name darf nicht leer sein")
return v
class ContentReportPatch(BaseModel):
status: Optional[str] = None
resolution_note: Optional[str] = Field(None, max_length=4000)
assigned_to_profile_id: Optional[int] = None
@field_validator("status")
@classmethod
def validate_status(cls, v: Optional[str]) -> Optional[str]:
if v is None:
return v
allowed = {"submitted", "under_review", "resolved_no_action",
"resolved_legal_hold", "rejected_invalid"}
if v not in allowed:
raise ValueError(f"Ungueltiger status. Erlaubt: {sorted(allowed)}")
return v
class ContentReportLegalHoldBody(BaseModel):
reason_note: str = Field(..., min_length=5, max_length=2000)
# ─── Hilfsfunktionen ────────────────────────────────────────────────────────
def _assert_can_manage_report(cur, role: Optional[str], pid: int, report: dict) -> None:
"""Platform-Admins: jede Meldung. Club-Admin: nur Meldungen zu Medien ihres Vereins."""
if is_platform_admin(role):
return
if report.get("target_type") != "media_asset":
raise HTTPException(status_code=403, detail="Nur Plattform-Admins können Meldungen zu Übungen bearbeiten")
cur.execute("SELECT club_id FROM media_assets WHERE id = %s", (int(report["target_id"]),))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
club_id = r2d(row).get("club_id")
if not club_id:
raise HTTPException(status_code=403, detail="Kein Zugriff Medium ohne Vereinszugehörigkeit")
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s
AND cm.status = 'active' AND r.role_code = 'club_admin'
""",
(pid, club_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=403, detail="Kein Zugriff auf diese Meldung")
def _assert_can_set_legal_hold_from_report(cur, role: Optional[str], pid: int, asset_id: int) -> None:
"""Superadmin: immer. Club-Admin: nur für Vereinsmedien mit visibility != 'official'."""
if is_superadmin(role):
return
cur.execute("SELECT club_id, visibility FROM media_assets WHERE id = %s", (asset_id,))
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
if asset.get("visibility") == "official":
raise HTTPException(
status_code=403,
detail="Legal Hold auf offiziellen Medien erfordert Superadmin-Rechte",
)
club_id = asset.get("club_id")
if not club_id:
raise HTTPException(status_code=403, detail="Legal Hold erfordert Superadmin-Rechte für Medien ohne Vereinszugehörigkeit")
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s
AND cm.status = 'active' AND r.role_code = 'club_admin'
""",
(pid, club_id),
)
if cur.fetchone() is None:
raise HTTPException(status_code=403, detail="Kein Zugriff Superadmin oder Vereinsadmin des Vereins erforderlich")
def _is_media_asset_visible_anonymous(cur, asset_id: int) -> bool:
"""True wenn das Medium als 'official' und aktiv gilt (fuer anonyme Meldung zugaenglich)."""
cur.execute(
"""
SELECT 1 FROM media_assets
WHERE id = %s
AND visibility = 'official'
AND lifecycle_state = 'active'
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)
""",
(asset_id,),
)
return cur.fetchone() is not None
def _is_media_asset_visible_to_profile(cur, asset_id: int, profile_id: int, global_role: Optional[str]) -> bool:
"""Prueft ob ein eingeloggter Nutzer das Medium sehen darf."""
if is_platform_admin(global_role):
cur.execute("SELECT 1 FROM media_assets WHERE id = %s", (asset_id,))
return cur.fetchone() is not None
cur.execute(
"""
SELECT ma.id, ma.visibility, ma.club_id, ma.uploaded_by_profile_id
FROM media_assets ma
WHERE ma.id = %s
AND ma.lifecycle_state = 'active'
AND (ma.legal_hold_active = FALSE OR ma.legal_hold_active IS NULL)
""",
(asset_id,),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
visibility = asset.get("visibility")
if visibility == "official":
return True
if visibility == "club":
club_id = asset.get("club_id")
if not club_id:
return False
cur.execute(
"""
SELECT 1 FROM club_members
WHERE profile_id = %s AND club_id = %s AND status = 'active'
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
if visibility == "private":
if asset.get("uploaded_by_profile_id") == profile_id:
return True
club_id = asset.get("club_id")
if club_id:
cur.execute(
"""
SELECT 1 FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.club_id = %s AND cm.status = 'active'
AND r.role_code = 'club_admin'
""",
(profile_id, club_id),
)
return cur.fetchone() is not None
return False
def _is_exercise_visible_to_profile(cur, exercise_id: int, profile_id: Optional[int], global_role: Optional[str]) -> bool:
"""Vereinfachte Sichtbarkeitspruefung fuer Uebungen (fuer Meldungen)."""
if profile_id and is_platform_admin(global_role):
cur.execute("SELECT 1 FROM exercises WHERE id = %s AND status != 'deleted'", (exercise_id,))
return cur.fetchone() is not None
if profile_id is None:
cur.execute(
"SELECT 1 FROM exercises WHERE id = %s AND visibility = 'official' AND status = 'active'",
(exercise_id,),
)
return cur.fetchone() is not None
cur.execute(
"""
SELECT e.visibility, e.club_id, e.created_by_profile_id
FROM exercises e WHERE e.id = %s AND e.status != 'deleted'
""",
(exercise_id,),
)
row = cur.fetchone()
if not row:
return False
ex = r2d(row)
if ex.get("visibility") == "official":
return True
if ex.get("visibility") == "club":
club_id = ex.get("club_id")
if not club_id:
return False
cur.execute(
"SELECT 1 FROM club_members WHERE profile_id = %s AND club_id = %s AND status = 'active'",
(profile_id, club_id),
)
return cur.fetchone() is not None
if ex.get("visibility") == "private":
return ex.get("created_by_profile_id") == profile_id
return False
def _resolve_optional_auth(cur, x_auth_token: Optional[str]) -> tuple[Optional[int], Optional[str]]:
"""Gibt (profile_id, global_role) zurueck wenn Token gueltig, sonst (None, None)."""
if not x_auth_token:
return None, None
cur.execute(
"""
SELECT p.id AS profile_id, p.role
FROM sessions s
INNER JOIN profiles p ON p.id = s.profile_id
WHERE s.token = %s
AND (s.expires_at IS NULL OR s.expires_at > NOW())
""",
(x_auth_token,),
)
row = cur.fetchone()
if not row:
return None, None
d = r2d(row)
return d.get("profile_id"), d.get("role")
def _get_platform_admin_emails(cur) -> list[str]:
"""Gibt E-Mail-Adressen aller aktiven Plattform-Admins zurueck."""
cur.execute(
"""
SELECT email FROM profiles
WHERE role IN ('admin', 'superadmin')
AND email IS NOT NULL AND email <> ''
"""
)
return [r2d(row)["email"] for row in cur.fetchall()]
def _send_email(to: str, subject: str, body: str) -> None:
"""SMTP-Versand (best-effort, ignoriert Fehler und nicht konfiguriertes SMTP)."""
try:
import smtplib, ssl
from email.mime.text import MIMEText
smtp_host = (os.getenv("SMTP_HOST") or "").strip()
smtp_user = (os.getenv("SMTP_USER") or "").strip()
smtp_pass = os.getenv("SMTP_PASS") or ""
if not smtp_host or not smtp_user or not smtp_pass:
return
smtp_port = int(os.getenv("SMTP_PORT") or "587")
smtp_from = os.getenv("SMTP_FROM", "noreply@jinkendo.de")
force_ssl = (os.getenv("SMTP_SSL", "").strip().lower() in ("1", "true", "yes")) or smtp_port == 465
use_tls = os.getenv("SMTP_STARTTLS", "true").strip().lower() not in ("0", "false", "no")
msg = MIMEText(body, "plain", "utf-8")
msg["Subject"] = subject
msg["From"] = smtp_from
msg["To"] = to
ctx = ssl.create_default_context()
if force_ssl:
with smtplib.SMTP_SSL(smtp_host, smtp_port, context=ctx, timeout=30) as srv:
srv.login(smtp_user, smtp_pass)
srv.send_message(msg)
else:
with smtplib.SMTP(smtp_host, smtp_port, timeout=30) as srv:
srv.ehlo()
if use_tls:
srv.starttls(context=ctx)
srv.ehlo()
srv.login(smtp_user, smtp_pass)
srv.send_message(msg)
except Exception as exc:
print(f"[SMTP content_reports] Fehler: {exc}")
def _notify_report_submitted(
*,
report_id: int,
reporter_name: str,
reporter_email: str,
reason: str,
priority: str,
target_type: str,
target_id: int,
target_label: str,
admin_emails: list[str],
) -> None:
"""Sendet Bestaetigungs-Mail an Melder + Benachrichtigung an alle Plattform-Admins (best-effort)."""
app_url = (os.getenv("APP_URL") or "https://shinkan.jinkendo.de").rstrip("/")
reason_label = REASON_LABELS_DE.get(reason, reason)
prio_label = "DRINGEND" if priority == "high" else "normal"
# Bestaetigungs-Mail an Melder
confirmation_body = (
f"Hallo {reporter_name},\n\n"
f"Ihre Meldung (#{report_id}) wurde entgegengenommen.\n\n"
f"Meldegrund: {reason_label}\n"
f"Gemeldeter Inhalt: {target_label}\n\n"
f"Ein Administrator wird Ihre Meldung zeitnah prüfen. Sie müssen nichts weiter tun.\n\n"
f"Shinkan Jinkendo\n"
f"{app_url}"
)
_send_email(reporter_email, f"Meldung #{report_id} eingegangen Shinkan Jinkendo", confirmation_body)
# Admin-Benachrichtigung
admin_body = (
f"Neue Inhaltsmeldung #{report_id} [{prio_label}]\n\n"
f"Meldegrund: {reason_label}\n"
f"Gemeldeter Inhalt: {target_label}\n"
f"Gemeldet von: {reporter_name} <{reporter_email}>\n\n"
f"Posteingang öffnen: {app_url}/inbox\n"
)
subject = f"Inhaltsmeldung #{report_id} [{prio_label}] Shinkan Jinkendo"
for email in admin_emails:
_send_email(email, subject, admin_body)
def _report_row_to_dict(row) -> dict:
return r2d(row)
# ─── Endpoints ──────────────────────────────────────────────────────────────
@router.post("/content-reports", status_code=201)
def submit_content_report(
body: ContentReportCreate,
x_auth_token: Optional[str] = Header(default=None),
):
"""
Meldung einreichen (optionale Authentifizierung).
- Ohne Login: nur fuer 'official'-Medien erlaubt (öffentlich erreichbare Inhalte)
- Mit Login: fuer alle Inhalte, die der Nutzer laut Sichtbarkeitslogik sehen darf
- Gutglaubenserklärung (good_faith_confirmed=true) ist Pflicht
- Journaleintrag im Medien-Audit-Log (wenn target_type=media_asset)
- E-Mail-Bestaetigung an Melder + Benachrichtigung an Plattform-Admins (best-effort)
"""
if not body.good_faith_confirmed:
raise HTTPException(
status_code=400,
detail="Die Gutglaubenserklärung (good_faith_confirmed) muss bestätigt werden.",
)
priority = "high" if body.report_reason in HIGH_PRIORITY_REASONS else "normal"
report_id: int
admin_emails: list[str] = []
with get_db() as conn:
cur = get_cursor(conn)
# Optionale Auth aufloesen
profile_id, global_role = _resolve_optional_auth(cur, x_auth_token)
# Sichtbarkeit pruefen
if body.target_type == "media_asset":
if profile_id:
visible = _is_media_asset_visible_to_profile(cur, body.target_id, profile_id, global_role)
else:
visible = _is_media_asset_visible_anonymous(cur, body.target_id)
if not visible:
raise HTTPException(
status_code=404,
detail="Das gemeldete Medium existiert nicht oder ist nicht zugänglich.",
)
elif body.target_type == "exercise":
visible = _is_exercise_visible_to_profile(cur, body.target_id, profile_id, global_role)
if not visible:
raise HTTPException(
status_code=404,
detail="Die gemeldete Übung existiert nicht oder ist nicht zugänglich.",
)
cur.execute(
"""
INSERT INTO content_reports (
target_type, target_id, report_reason, report_description,
reporter_name, reporter_email, reporter_profile_id,
good_faith_confirmed, priority, status,
submitted_at, created_at, updated_at
)
VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, 'submitted', NOW(), NOW(), NOW())
RETURNING id, status, priority, submitted_at
""",
(
body.target_type,
body.target_id,
body.report_reason,
body.report_description.strip(),
body.reporter_name,
body.reporter_email,
profile_id,
body.good_faith_confirmed,
priority,
),
)
row = cur.fetchone()
result = r2d(row)
report_id = int(result["id"])
# Dateinamen / Zielbezeichnung fuer E-Mail abfragen
target_label_for_email = f"{'Medium' if body.target_type == 'media_asset' else 'Übung'} #{body.target_id}"
if body.target_type == "media_asset":
cur.execute("SELECT original_filename FROM media_assets WHERE id = %s", (body.target_id,))
fn_row = cur.fetchone()
if fn_row:
fn = r2d(fn_row).get("original_filename") or ""
if fn:
target_label_for_email = f"{fn} (Medium #{body.target_id})"
elif body.target_type == "exercise":
cur.execute("SELECT title FROM exercises WHERE id = %s", (body.target_id,))
ex_row = cur.fetchone()
if ex_row:
ex_name = r2d(ex_row).get("title") or ""
if ex_name:
target_label_for_email = f"{ex_name} (Übung #{body.target_id})"
# Audit-Log-Eintrag (nur fuer media_asset-Meldungen)
if body.target_type == "media_asset":
write_audit_log_entry(
cur,
body.target_id,
profile_id,
"content_report_filed",
{},
{
"content_report_id": report_id,
"report_reason": REASON_LABELS_DE.get(body.report_reason, body.report_reason),
"priority": "hoch" if priority == "high" else "normal",
},
)
# Admin-E-Mails vor Commit abfragen (Verbindung noch offen)
admin_emails = _get_platform_admin_emails(cur)
conn.commit()
# E-Mails nach Commit senden (best-effort, kein Rollback-Risiko)
_notify_report_submitted(
report_id=report_id,
reporter_name=body.reporter_name,
reporter_email=body.reporter_email,
reason=body.report_reason,
priority=priority,
target_type=body.target_type,
target_id=body.target_id,
target_label=target_label_for_email,
admin_emails=admin_emails,
)
return {
"id": report_id,
"status": result["status"],
"priority": result["priority"],
"submitted_at": str(result["submitted_at"]),
"message": "Ihre Meldung wurde entgegengenommen. Ein Administrator wird sie prüfen.",
}
@router.get("/me/inbox/content-reports")
def list_inbox_content_reports(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Meldungen fuer die Inbox.
- Plattform-Admins (admin/superadmin): alle Meldungen
- Club-Admins: nur Meldungen ueber Medien ihres Vereins
"""
pid = tenant.profile_id
role = tenant.global_role
is_padmin = is_platform_admin(role)
with get_db() as conn:
cur = get_cursor(conn)
if is_padmin:
# Plattform-Admin: alle Meldungen
cur.execute(
"""
SELECT
cr.id,
cr.target_type,
cr.target_id,
cr.report_reason,
cr.report_description,
cr.reporter_name,
cr.reporter_email,
cr.reporter_profile_id,
cr.priority,
cr.status,
cr.assigned_to_profile_id,
cr.reviewed_by_profile_id,
cr.reviewed_at,
cr.resolution_note,
cr.submitted_at,
cr.created_at,
cr.updated_at,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ex.title AS target_exercise_name,
rev.name AS reviewed_by_name,
asgn.name AS assigned_to_name
FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
ORDER BY
CASE WHEN cr.status = 'submitted' THEN 0
WHEN cr.status = 'under_review' THEN 1
ELSE 2 END ASC,
cr.priority DESC,
cr.created_at ASC
LIMIT 500
""",
)
else:
# Club-Admin: Meldungen ueber Medien der eigenen Vereine
# Zuerst pruefen ob ueberhaupt Club-Admin-Rolle vorhanden
cur.execute(
"""
SELECT COUNT(*) AS cnt
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin'
""",
(pid,),
)
cnt_row = r2d(cur.fetchone())
if int(cnt_row.get("cnt", 0)) == 0:
raise HTTPException(status_code=403, detail="Kein Zugriff auf Inhaltsmeldungen")
cur.execute(
"""
SELECT
cr.id,
cr.target_type,
cr.target_id,
cr.report_reason,
cr.report_description,
cr.reporter_name,
cr.reporter_email,
cr.reporter_profile_id,
cr.priority,
cr.status,
cr.assigned_to_profile_id,
cr.reviewed_by_profile_id,
cr.reviewed_at,
cr.resolution_note,
cr.submitted_at,
cr.created_at,
cr.updated_at,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ex.title AS target_exercise_name,
rev.name AS reviewed_by_name,
asgn.name AS assigned_to_name
FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
WHERE cr.target_type = 'media_asset'
AND ma.club_id IN (
SELECT cm.club_id
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.status = 'active' AND r.role_code = 'club_admin'
)
ORDER BY
CASE WHEN cr.status = 'submitted' THEN 0
WHEN cr.status = 'under_review' THEN 1
ELSE 2 END ASC,
cr.priority DESC,
cr.created_at ASC
LIMIT 200
""",
(pid,),
)
return [_report_row_to_dict(row) for row in cur.fetchall()]
@router.get("/content-reports/{report_id}")
def get_content_report(
report_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Detail-Ansicht einer Meldung fuer Plattform-Admins und zustaendige Club-Admins."""
pid = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT
cr.*,
ma.original_filename AS target_filename,
ma.visibility AS target_visibility,
ma.mime_type AS target_mime_type,
ma.legal_hold_active AS target_legal_hold_active,
ma.legal_hold_reason_code AS target_legal_hold_reason_code,
ex.title AS target_exercise_name,
ex.visibility AS target_exercise_visibility,
rev.name AS reviewed_by_name,
asgn.name AS assigned_to_name,
rep.name AS reporter_profile_name
FROM content_reports cr
LEFT JOIN media_assets ma ON ma.id = cr.target_id AND cr.target_type = 'media_asset'
LEFT JOIN exercises ex ON ex.id = cr.target_id AND cr.target_type = 'exercise'
LEFT JOIN profiles rev ON rev.id = cr.reviewed_by_profile_id
LEFT JOIN profiles asgn ON asgn.id = cr.assigned_to_profile_id
LEFT JOIN profiles rep ON rep.id = cr.reporter_profile_id
WHERE cr.id = %s
""",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
report_dict = _report_row_to_dict(row)
_assert_can_manage_report(cur, role, pid, report_dict)
return report_dict
@router.patch("/content-reports/{report_id}")
def patch_content_report(
report_id: int,
body: ContentReportPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Status und Bearbeitungsnotiz einer Meldung aktualisieren.
Abschluss ohne Massnahme (resolved_no_action, rejected_invalid) erfordert resolution_note.
Plattform-Admins: jede Meldung. Club-Admins: nur Meldungen zu Medien ihres Vereins.
"""
pid = tenant.profile_id
role = tenant.global_role
if body.status in ("resolved_no_action", "rejected_invalid") and not (body.resolution_note or "").strip():
raise HTTPException(
status_code=400,
detail="Bei Abschluss ohne Massnahme ist eine Begründung (resolution_note) Pflicht.",
)
STATUS_LABELS_DE = {
"submitted": "Eingegangen",
"under_review": "In Bearbeitung",
"resolved_no_action": "Abgeschlossen (kein Handlungsbedarf)",
"resolved_legal_hold": "Abgeschlossen (Legal Hold)",
"rejected_invalid": "Abgewiesen (ungültig)",
}
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status, resolution_note, target_type, target_id FROM content_reports WHERE id = %s",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
old_report = r2d(row)
_assert_can_manage_report(cur, role, pid, old_report)
old_status = old_report["status"]
old_note = (old_report.get("resolution_note") or "").strip()
updates = ["updated_at = NOW()"]
params = []
if body.status is not None:
updates.append("status = %s")
params.append(body.status)
if body.status in ("resolved_no_action", "resolved_legal_hold", "rejected_invalid"):
updates.append("reviewed_by_profile_id = %s")
updates.append("reviewed_at = NOW()")
params.append(pid)
elif body.status == "submitted":
# Wieder öffnen: Prüferfelder zurücksetzen
updates.append("reviewed_by_profile_id = NULL")
updates.append("reviewed_at = NULL")
if body.resolution_note is not None:
updates.append("resolution_note = %s")
params.append(body.resolution_note.strip() or None)
if body.assigned_to_profile_id is not None:
updates.append("assigned_to_profile_id = %s")
params.append(body.assigned_to_profile_id)
if len(updates) == 1:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren angegeben")
params.append(report_id)
cur.execute(
f"UPDATE content_reports SET {', '.join(updates)} WHERE id = %s RETURNING id, status",
params,
)
updated = r2d(cur.fetchone())
new_status = updated["status"]
is_media = old_report["target_type"] == "media_asset"
if is_media:
new_note = (body.resolution_note or "").strip() if body.resolution_note is not None else old_note
# Audit-Log: Statuswechsel
if body.status is not None and old_status != new_status:
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"status": STATUS_LABELS_DE.get(old_status, old_status)},
{
"content_report_id": report_id,
"status": STATUS_LABELS_DE.get(new_status, new_status),
**({"begründung": new_note} if new_note else {}),
},
)
# Audit-Log: reine Notizänderung (ohne Statuswechsel)
elif body.resolution_note is not None and new_note != old_note:
write_audit_log_entry(
cur,
int(old_report["target_id"]),
pid,
"content_report_filed",
{"content_report_id": report_id, "begründung": old_note or None},
{"content_report_id": report_id, "begründung": new_note or None},
)
conn.commit()
return {"ok": True, "id": updated["id"], "status": updated["status"]}
@router.post("/content-reports/{report_id}/legal-hold")
def set_legal_hold_from_report(
report_id: int,
body: ContentReportLegalHoldBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Legal Hold (P-11) aus einer Meldung heraus setzen.
Superadmin: immer. Club-Admin: nur fuer Vereinsmedien (visibility != 'official').
Nur fuer Meldungen mit target_type='media_asset'.
Der Reason-Code wird automatisch aus dem report_reason der Meldung abgeleitet.
Nach dem Setzen wird der Report-Status auf 'resolved_legal_hold' gesetzt.
"""
pid = tenant.profile_id
role = tenant.global_role
# Plattform-Admin ohne Superadmin hat keine Legal-Hold-Rechte (fruehzeitig ablehnen)
if is_platform_admin(role) and not is_superadmin(role):
raise HTTPException(
status_code=403,
detail="Legal Hold erfordert Superadmin oder Vereinsadmin-Rechte",
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, target_type, target_id, report_reason, status FROM content_reports WHERE id = %s",
(report_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Meldung nicht gefunden")
report = r2d(row)
if report["target_type"] != "media_asset":
raise HTTPException(
status_code=400,
detail="Legal Hold ist nur fuer Meldungen mit target_type='media_asset' verfügbar.",
)
asset_id = int(report["target_id"])
_assert_can_set_legal_hold_from_report(cur, role, pid, asset_id)
reason_code = _REASON_TO_HOLD_CODE.get(report["report_reason"], "other")
# P-11-Service aufrufen
set_legal_hold(
cur=cur,
conn=conn,
asset_id=asset_id,
acting_profile_id=pid,
reason_code=reason_code,
reason_note=f"[P-13 Meldung #{report_id}] {body.reason_note.strip()}",
)
# set_legal_hold committed bereits — neue Transaktion fuer Report-Update
cur.execute(
"""
UPDATE content_reports
SET status = 'resolved_legal_hold',
reviewed_by_profile_id = %s,
reviewed_at = NOW(),
resolution_note = %s,
updated_at = NOW()
WHERE id = %s
RETURNING id, status
""",
(pid, f"Legal Hold gesetzt auf Asset #{asset_id} (reason_code: {reason_code})", report_id),
)
updated = r2d(cur.fetchone())
conn.commit()
return {
"ok": True,
"report_id": updated["id"],
"report_status": updated["status"],
"asset_id": asset_id,
"legal_hold_reason_code": reason_code,
}

View File

@ -29,6 +29,8 @@ from club_tenancy import (
)
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
from media_rights import assert_rights_for_exercise_link, validate_rights_declaration, write_rights_declaration, update_rights_quick_fields
from media_legal_hold import assert_not_under_legal_hold
from exercise_rich_text import (
RICH_HTML_EXERCISE_FIELDS,
assert_no_inline_media_references_on_create,
@ -921,7 +923,8 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
"""SELECT em.id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context,
em.media_asset_id, ma.copyright_notice AS asset_copyright_notice,
ma.lifecycle_state AS asset_lifecycle_state
ma.lifecycle_state AS asset_lifecycle_state,
ma.legal_hold_active AS asset_legal_hold_active
FROM exercise_media em
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
WHERE em.exercise_id = %s
@ -2464,7 +2467,8 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
"""SELECT em.id, em.exercise_id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.created_at,
em.media_asset_id, ma.storage_key AS asset_storage_key,
ma.lifecycle_state AS asset_lifecycle_state
ma.lifecycle_state AS asset_lifecycle_state,
ma.legal_hold_active AS asset_legal_hold_active
FROM exercise_media em
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
WHERE em.id = %s AND em.exercise_id = %s""",
@ -2494,6 +2498,12 @@ def download_exercise_media_file(
if (media.get("embed_url") or "").strip():
raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL")
if bool(media.get("asset_legal_hold_active")):
raise HTTPException(
status_code=451,
detail={"code": "LEGAL_HOLD_ACTIVE", "message": "Dieses Medium ist gesperrt und steht nicht zur Verfügung."}
)
lc = (media.get("asset_lifecycle_state") or "active").strip().lower()
if lc == "trash_hidden":
_assert_can_edit_exercise(cur, exercise_id, tenant)
@ -2526,6 +2536,21 @@ async def upload_exercise_media(
description: str = Form(""),
context: str = Form("ablauf"),
is_primary: bool = Form(False),
copyright_notice: Optional[str] = Form(None),
# P-06: Rechte-Erklaerung (Pflicht bei Datei-Upload mit neuem media_asset)
rights_holder_confirmed: Optional[bool] = Form(None),
contains_identifiable_persons: Optional[bool] = Form(None),
person_consent_confirmed: Optional[bool] = Form(None),
person_consent_context: Optional[str] = Form(None),
contains_minors: Optional[bool] = Form(None),
parental_consent_confirmed: Optional[bool] = Form(None),
parental_consent_context: Optional[str] = Form(None),
contains_music: Optional[bool] = Form(None),
music_rights_confirmed: Optional[bool] = Form(None),
music_rights_context: Optional[str] = Form(None),
contains_third_party_content: Optional[bool] = Form(None),
third_party_rights_confirmed: Optional[bool] = Form(None),
third_party_rights_context: Optional[str] = Form(None),
):
profile_id = tenant.profile_id
if media_type not in ("image", "video", "document", "sketch"):
@ -2764,11 +2789,12 @@ async def upload_exercise_media(
if not dest_path.is_file():
dest_path.write_bytes(raw)
clean_cr = (copyright_notice or "").strip() or None
cur.execute(
"""INSERT INTO media_assets (
mime_type, byte_size, sha256, original_filename, visibility, club_id,
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active')
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active')
RETURNING id""",
(
mime,
@ -2778,11 +2804,31 @@ async def upload_exercise_media(
ex_vis,
dedupe_club,
profile_id,
clean_cr,
storage_key,
),
)
ar = cur.fetchone()
aid = r2d(ar)["id"]
# P-06: Rechterklaerung fuer neues Media-Asset validieren und schreiben
p06_decl = {
"rights_holder_confirmed": rights_holder_confirmed,
"contains_identifiable_persons": contains_identifiable_persons,
"person_consent_confirmed": person_consent_confirmed,
"person_consent_context": person_consent_context,
"contains_minors": contains_minors,
"parental_consent_confirmed": parental_consent_confirmed,
"parental_consent_context": parental_consent_context,
"contains_music": contains_music,
"music_rights_confirmed": music_rights_confirmed,
"music_rights_context": music_rights_context,
"contains_third_party_content": contains_third_party_content,
"third_party_rights_confirmed": third_party_rights_confirmed,
"third_party_rights_context": third_party_rights_context,
}
validate_rights_declaration(p06_decl, ex_vis)
write_rights_declaration(cur, aid, profile_id, "upload", ex_vis, p06_decl)
update_rights_quick_fields(cur, aid, ex_vis)
db_path = f"/media/{storage_key}"
cur.execute(
f"""INSERT INTO exercise_media (
@ -2843,7 +2889,7 @@ def attach_exercise_media_from_asset(
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, storage_key, lifecycle_state
uploaded_by_profile_id, storage_key, lifecycle_state, legal_hold_active
FROM media_assets WHERE id = %s""",
(body.media_asset_id,),
)
@ -2856,6 +2902,8 @@ def attach_exercise_media_from_asset(
status_code=400,
detail="Nur aktive Archiv-Medien können verknüpft werden",
)
# P-11: Legal Hold verhindert neue Einbindung in Übungen
assert_not_under_legal_hold(asset)
if not library_content_visible_to_profile(
cur,
@ -2867,6 +2915,15 @@ def attach_exercise_media_from_asset(
):
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Archiv-Medium")
# P-06: Rechtspruefung — Asset muss Einwilligung fuer Sichtbarkeit der Uebung haben
cur.execute(
"SELECT visibility FROM exercises WHERE id = %s",
(exercise_id,),
)
ex_row = cur.fetchone()
ex_vis = (r2d(ex_row).get("visibility") or "private").strip().lower() if ex_row else "private"
assert_rights_for_exercise_link(cur, body.media_asset_id, ex_vis)
cur.execute(
"SELECT 1 FROM exercise_media WHERE exercise_id = %s AND media_asset_id = %s",
(exercise_id, body.media_asset_id),

View File

@ -0,0 +1,475 @@
"""
Rechtstexte (Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie).
Öffentlich (kein Auth):
GET /api/legal-documents/{document_type}/published
Superadmin only:
GET /api/admin/legal-documents Liste aller Versionen
POST /api/admin/legal-documents Neuen Entwurf anlegen
GET /api/admin/legal-documents/{id} Einzelnes Dokument
PUT /api/admin/legal-documents/{id} Entwurf bearbeiten
POST /api/admin/legal-documents/{id}/publish Veröffentlichen
POST /api/admin/legal-documents/{id}/archive Archivieren
GET /api/admin/legal-documents/{id}/audit Änderungslog
"""
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from auth import require_auth
from club_tenancy import is_superadmin
from db import get_db, get_cursor, r2d
# ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug.
# Öffentlicher GET-Endpoint ohne jegliche Auth; Admin-Endpoints nutzen require_auth + is_superadmin().
# Eingetragen in backend/scripts/check_access_layer_hints.py EXEMPT_ROUTERS.
router = APIRouter(tags=["legal_documents"])
VALID_TYPES = {"impressum", "privacy_policy", "terms_of_use", "media_policy"}
def _require_superadmin(session: dict):
role = (session.get("role") or "").lower()
if not is_superadmin(role):
raise HTTPException(status_code=403, detail="Nur Superadmins")
return session
class LegalDocumentCreate(BaseModel):
document_type: str
title: str
content_sections: List[Dict[str, Any]] = []
change_note: Optional[str] = None
class LegalDocumentUpdate(BaseModel):
title: Optional[str] = None
content_sections: Optional[List[Dict[str, Any]]] = None
change_note: Optional[str] = None
class PublishRequest(BaseModel):
change_note: Optional[str] = None
# ─── Public endpoint ────────────────────────────────────────────────────────
@router.get("/api/legal-documents/{document_type}/published")
def get_published_legal_document(document_type: str):
"""Liefert das aktuell veröffentlichte Dokument oder null. Kein Auth erforderlich."""
if document_type not in VALID_TYPES:
raise HTTPException(status_code=404, detail="Unbekannter Dokumententyp")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
FROM legal_documents
WHERE document_type = %s AND status = 'published'
LIMIT 1
""",
(document_type,),
)
row = cur.fetchone()
if not row:
return None
doc = r2d(row)
return doc
# ─── Superadmin endpoints ────────────────────────────────────────────────────
@router.get("/api/admin/legal-documents")
def list_legal_documents(
document_type: Optional[str] = None,
session: dict = Depends(require_auth),
):
"""Alle Versionen aller Rechtstexte (oder gefiltert nach document_type)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
if document_type:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
WHERE d.document_type = %s
ORDER BY d.document_type, d.version DESC
""",
(document_type,),
)
else:
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.status,
d.change_note, d.created_at, d.updated_at, d.published_at,
p.name AS created_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
ORDER BY d.document_type, d.version DESC
"""
)
rows = cur.fetchall()
return [r2d(r) for r in rows]
@router.post("/api/admin/legal-documents", status_code=201)
def create_legal_document(
body: LegalDocumentCreate,
session: dict = Depends(require_auth),
):
"""Neuen Entwurf anlegen. Versionsnummer = max(vorherige) + 1."""
_require_superadmin(session)
if body.document_type not in VALID_TYPES:
raise HTTPException(status_code=422, detail="Ungültiger document_type")
profile_id = session["profile_id"]
import json as _json
sections_json = _json.dumps(body.content_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(version), 0) FROM legal_documents WHERE document_type = %s",
(body.document_type,),
)
row = cur.fetchone()
next_version = list(row.values())[0] + 1
cur.execute(
"""
INSERT INTO legal_documents
(document_type, version, title, content_sections, status, change_note, created_by_profile_id)
VALUES (%s, %s, %s, %s::jsonb, 'draft', %s, %s)
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(body.document_type, next_version, body.title, sections_json, body.change_note, profile_id),
)
new_row = cur.fetchone()
new_id = list(new_row.values())[0]
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'created', %s, %s)
""",
(new_id, profile_id, body.change_note),
)
conn.commit()
return r2d(new_row)
@router.get("/api/admin/legal-documents/{doc_id}")
def get_legal_document(doc_id: int, session: dict = Depends(require_auth)):
"""Einzelnes Dokument (alle Felder inkl. content_sections)."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT d.id, d.document_type, d.version, d.title, d.content_sections,
d.status, d.change_note, d.created_at, d.updated_at,
d.published_at, d.created_by_profile_id,
p.name AS created_by_name,
pb.name AS published_by_name
FROM legal_documents d
LEFT JOIN profiles p ON p.id = d.created_by_profile_id
LEFT JOIN profiles pb ON pb.id = d.published_by_profile_id
WHERE d.id = %s
""",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
return r2d(row)
@router.put("/api/admin/legal-documents/{doc_id}")
def update_legal_document(
doc_id: int,
body: LegalDocumentUpdate,
session: dict = Depends(require_auth),
):
"""Entwurf bearbeiten. Nur status='draft' kann bearbeitet werden."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status, title, content_sections, change_note FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] != "draft":
raise HTTPException(status_code=409, detail="Nur Entwürfe können bearbeitet werden")
import json as _json
new_title = body.title if body.title is not None else doc["title"]
new_sections = body.content_sections if body.content_sections is not None else doc["content_sections"]
new_note = body.change_note if body.change_note is not None else doc["change_note"]
sections_json = _json.dumps(new_sections, ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET title = %s, content_sections = %s::jsonb, change_note = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(new_title, sections_json, new_note, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit (legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'updated', %s, %s)
""",
(doc_id, profile_id, new_note),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/publish")
def publish_legal_document(
doc_id: int,
body: PublishRequest = PublishRequest(),
session: dict = Depends(require_auth),
):
"""
Veröffentlicht dieses Dokument. Ein bisher veröffentlichtes Dokument desselben
Typs wird automatisch auf 'archived' gesetzt (partial unique index erzwingt dies).
"""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, document_type, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "published":
raise HTTPException(status_code=409, detail="Dokument ist bereits veröffentlicht")
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Archivierte Dokumente können nicht veröffentlicht werden")
with get_db() as conn:
cur = get_cursor(conn)
# Vorheriges published-Dokument desselben Typs archivieren
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE document_type = %s AND status = 'published' AND id != %s
""",
(doc["document_type"], doc_id),
)
# Dieses Dokument veröffentlichen
cur.execute(
"""
UPDATE legal_documents
SET status = 'published', published_at = NOW(),
published_by_profile_id = %s, updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, content_sections,
status, change_note, published_at, updated_at
""",
(profile_id, doc_id),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, change_note, previous_status)
VALUES (%s, 'published', %s, %s, %s)
""",
(doc_id, profile_id, body.change_note, doc["status"]),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/archive")
def archive_legal_document(
doc_id: int,
session: dict = Depends(require_auth),
):
"""Archiviert ein Dokument. Veröffentlichte Dokumente dürfen nur archiviert werden wenn kein anderes published ist."""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, status FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
doc = r2d(row)
if doc["status"] == "archived":
raise HTTPException(status_code=409, detail="Dokument ist bereits archiviert")
prev_status = doc["status"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
UPDATE legal_documents
SET status = 'archived', updated_at = NOW()
WHERE id = %s
RETURNING id, document_type, version, title, status, updated_at
""",
(doc_id,),
)
updated = cur.fetchone()
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, previous_status)
VALUES (%s, 'archived', %s, %s)
""",
(doc_id, profile_id, prev_status),
)
conn.commit()
return r2d(updated)
@router.post("/api/admin/legal-documents/{doc_id}/copy-as-draft", status_code=201)
def copy_legal_document_as_draft(
doc_id: int,
session: dict = Depends(require_auth),
):
"""
Kopiert ein beliebiges Dokument (egal welcher Status) als neuen Entwurf mit
nächster Versionsnummer. Inhalt und Titel werden übernommen.
"""
_require_superadmin(session)
profile_id = session["profile_id"]
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, document_type, title, content_sections FROM legal_documents WHERE id = %s",
(doc_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
src = r2d(row)
import json as _json
sections_json = _json.dumps(src["content_sections"], ensure_ascii=False)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT COALESCE(MAX(version), 0) FROM legal_documents WHERE document_type = %s",
(src["document_type"],),
)
row2 = cur.fetchone()
next_version = list(row2.values())[0] + 1
cur.execute(
"""
INSERT INTO legal_documents
(document_type, version, title, content_sections, status, change_note, created_by_profile_id)
VALUES (%s, %s, %s, %s::jsonb, 'draft', NULL, %s)
RETURNING id, document_type, version, title, content_sections,
status, change_note, created_at, updated_at
""",
(src["document_type"], next_version, src["title"], sections_json, profile_id),
)
new_row = cur.fetchone()
new_id = list(new_row.values())[0]
cur.execute(
"""
INSERT INTO legal_document_audit
(legal_document_id, action, changed_by_profile_id, change_note)
VALUES (%s, 'created', %s, %s)
""",
(new_id, profile_id, f"Kopie von Version {src.get('version', '?')} (ID {doc_id})"),
)
conn.commit()
return r2d(new_row)
@router.get("/api/admin/legal-documents/{doc_id}/audit")
def get_legal_document_audit(doc_id: int, session: dict = Depends(require_auth)):
"""Änderungslog für ein Dokument."""
_require_superadmin(session)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute("SELECT id FROM legal_documents WHERE id = %s", (doc_id,))
if not cur.fetchone():
raise HTTPException(status_code=404, detail="Dokument nicht gefunden")
cur.execute(
"""
SELECT a.id, a.action, a.change_note, a.previous_status, a.created_at,
p.name AS changed_by_name
FROM legal_document_audit a
LEFT JOIN profiles p ON p.id = a.changed_by_profile_id
WHERE a.legal_document_id = %s
ORDER BY a.created_at DESC
""",
(doc_id,),
)
rows = cur.fetchall()
return [r2d(r) for r in rows]

View File

@ -13,6 +13,7 @@ from club_tenancy import (
assert_club_member,
assert_valid_governance_visibility,
club_ids_for_profile_with_roles,
has_club_role,
is_platform_admin,
is_superadmin,
library_content_visible_to_profile,
@ -37,6 +38,24 @@ from media_lifecycle import (
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root, relocate_local_media_file
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
from media_rights import (
assert_rights_for_promotion,
update_rights_quick_fields,
validate_rights_declaration,
write_rights_declaration,
write_audit_log_entry,
write_rights_correction_declaration,
check_rights_coverage,
VISIBILITY_LEVELS,
)
from media_legal_hold import (
LEGAL_HOLD_REASON_CODES,
assert_not_under_legal_hold,
assert_superadmin_for_legal_hold,
is_media_available_for_normal_use,
set_legal_hold,
release_legal_hold,
)
from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
@ -70,6 +89,57 @@ class MediaAssetPatch(BaseModel):
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
tags: Optional[list[str]] = None
# P-06: optionale Rechte-Erklaerung (wird Pflicht wenn Promotion zu hoeherer Sichtbarkeit)
rights_holder_confirmed: Optional[bool] = None
contains_identifiable_persons: Optional[bool] = None
person_consent_confirmed: Optional[bool] = None
person_consent_context: Optional[str] = Field(None, max_length=2000)
contains_minors: Optional[bool] = None
parental_consent_confirmed: Optional[bool] = None
parental_consent_context: Optional[str] = Field(None, max_length=2000)
contains_music: Optional[bool] = None
music_rights_confirmed: Optional[bool] = None
music_rights_context: Optional[str] = Field(None, max_length=2000)
contains_third_party_content: Optional[bool] = None
third_party_rights_confirmed: Optional[bool] = None
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
class RightsDeclarationBody(BaseModel):
"""P-06: Explizite Re-Deklaration / Nachdeklaration fuer ein bestehendes Medium."""
target_visibility: str = Field(..., pattern="^(private|club|official)$")
rights_holder_confirmed: bool
contains_identifiable_persons: bool
person_consent_confirmed: Optional[bool] = None
person_consent_context: Optional[str] = Field(None, max_length=2000)
contains_minors: bool
parental_consent_confirmed: Optional[bool] = None
parental_consent_context: Optional[str] = Field(None, max_length=2000)
contains_music: bool
music_rights_confirmed: Optional[bool] = None
music_rights_context: Optional[str] = Field(None, max_length=2000)
contains_third_party_content: bool
third_party_rights_confirmed: Optional[bool] = None
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
class RightsCorrectionBody(BaseModel):
"""P-06: Nachtraegliche Korrektur einer Erklaerung (append-only, neueste gilt)."""
target_visibility: str = Field(..., pattern="^(private|club|official)$")
rights_holder_confirmed: bool
contains_identifiable_persons: bool
person_consent_confirmed: Optional[bool] = None
person_consent_context: Optional[str] = Field(None, max_length=2000)
contains_minors: bool
parental_consent_confirmed: Optional[bool] = None
parental_consent_context: Optional[str] = Field(None, max_length=2000)
contains_music: bool
music_rights_confirmed: Optional[bool] = None
music_rights_context: Optional[str] = Field(None, max_length=2000)
contains_third_party_content: bool
third_party_rights_confirmed: Optional[bool] = None
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
correction_note: Optional[str] = Field(None, max_length=2000)
class MediaBulkLifecycleBody(BaseModel):
@ -100,7 +170,22 @@ class MediaBulkPatchBody(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
# P-06 Kontext (bei Promotion)
person_consent_context: Optional[str] = Field(None, max_length=2000)
parental_consent_context: Optional[str] = Field(None, max_length=2000)
music_rights_context: Optional[str] = Field(None, max_length=2000)
third_party_rights_context: Optional[str] = Field(None, max_length=2000)
tags: Optional[list[str]] = None
# P-06: Rechterklaerung (gilt fuer alle Assets des Batches bei Promotion)
rights_holder_confirmed: Optional[bool] = None
contains_identifiable_persons: Optional[bool] = None
person_consent_confirmed: Optional[bool] = None
contains_minors: Optional[bool] = None
parental_consent_confirmed: Optional[bool] = None
contains_music: Optional[bool] = None
music_rights_confirmed: Optional[bool] = None
contains_third_party_content: Optional[bool] = None
third_party_rights_confirmed: Optional[bool] = None
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
@ -379,8 +464,16 @@ def _relocate_asset_file_if_governance_changed(
def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str, list[Any]]:
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL."""
def _list_active_visibility_clause(
is_plat: bool,
profile_id: int,
include_legal_hold: bool = False,
) -> tuple[str, list[Any]]:
"""Sichtbare aktive Einträge: official; private (eigen oder Plattform-Admin); Verein wie Bibliotheks-SQL.
P-11: Legal-Hold-Medien sind für normale Nutzer nicht sichtbar.
is_plat/is_sup können include_legal_hold=True übergeben, um sie weiterhin zu sehen.
"""
parts = ["lower(trim(ma.visibility)) = 'official'"]
vals: list[Any] = []
@ -414,8 +507,13 @@ def _list_active_visibility_clause(is_plat: bool, profile_id: int) -> tuple[str,
)
vals.append(profile_id)
sql = "(" + " OR ".join(parts) + ")"
return sql, vals
vis_sql = "(" + " OR ".join(parts) + ")"
# P-11: Legal-Hold-Medien für normale Nutzer ausblenden
if not include_legal_hold:
vis_sql = f"({vis_sql} AND (ma.legal_hold_active = FALSE OR ma.legal_hold_active IS NULL))"
return vis_sql, vals
def _list_trash_visibility_clause(
@ -453,12 +551,15 @@ def _list_main_visibility_where(
) -> tuple[str, list[Any]]:
"""
Kombiniert lifecycle mit Leserechten. Papierkorb-Stufen für normale Nutzer stark eingeschränkt.
P-11: Plattform-Admins und Superadmins sehen Legal-Hold-Medien in der aktiven Liste.
"""
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
active_sql, active_params = _list_active_visibility_clause(is_plat, profile_id)
active_sql, active_params = _list_active_visibility_clause(
is_plat, profile_id, include_legal_hold=is_sup
)
trash_sql, trash_params = _list_trash_visibility_clause(
is_plat, is_sup, profile_id, admin_club_ids
)
@ -572,6 +673,7 @@ def _apply_lifecycle_action(
action = body.action
role_raw = tenant.global_role
old_lc = (asset.get("lifecycle_state") or LC_ACTIVE).strip()
if action == "superadmin_hard_delete":
if not is_superadmin(role_raw):
@ -586,7 +688,13 @@ def _apply_lifecycle_action(
raise HTTPException(status_code=403, detail="Nur Superadmin")
tl = body.target_lifecycle or "active"
mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN}
return superadmin_force_lifecycle_state(cur, conn, asset_id, mp[tl])
new_lc = mp[tl]
result = superadmin_force_lifecycle_state(cur, conn, asset_id, new_lc)
if old_lc != new_lc:
write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change",
{"lifecycle_state": old_lc}, {"lifecycle_state": new_lc})
conn.commit()
return result
if action == "purge":
if not is_superadmin(role_raw):
@ -603,16 +711,32 @@ def _apply_lifecycle_action(
if action == "trash_soft":
assert_can_trash_soft(cur, tenant, asset)
return transition_to_trash_soft(cur, conn, asset_id)
result = transition_to_trash_soft(cur, conn, asset_id)
write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change",
{"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_SOFT})
conn.commit()
return result
if action == "trash_hidden":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_to_trash_hidden(cur, conn, asset_id)
result = transition_to_trash_hidden(cur, conn, asset_id)
write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change",
{"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_HIDDEN})
conn.commit()
return result
if action == "recover":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_recover_from_hidden(cur, conn, asset_id)
result = transition_recover_from_hidden(cur, conn, asset_id)
write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change",
{"lifecycle_state": old_lc}, {"lifecycle_state": LC_TRASH_SOFT})
conn.commit()
return result
if action == "reactivate":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return reactivate_media_asset_from_trash(cur, conn, asset_id)
result = reactivate_media_asset_from_trash(cur, conn, asset_id)
write_audit_log_entry(cur, asset_id, tenant.profile_id, "lifecycle_change",
{"lifecycle_state": old_lc}, {"lifecycle_state": LC_ACTIVE})
conn.commit()
return result
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
@ -628,8 +752,14 @@ def _ingest_library_media_file(
content_type: Optional[str],
visibility: str,
club_id_form: Optional[int],
decl: Optional[dict] = None,
copyright_notice: Optional[str] = None,
) -> dict:
"""Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media."""
"""Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media.
decl: P-06 Rechterklaerung (bereits validiert). Bei Dedupe-Treffer wird eine neue Erklaerung
fuer die aktuelle Aktion geschrieben falls decl uebergeben wird.
"""
profile_id = tenant.profile_id
role = tenant.global_role or ""
vis = (visibility or "private").strip().lower()
@ -762,11 +892,12 @@ def _ingest_library_media_file(
if not dest_path.is_file():
dest_path.write_bytes(raw)
clean_cr = (copyright_notice or "").strip() or None
cur.execute(
"""INSERT INTO media_assets (
mime_type, byte_size, sha256, original_filename, visibility, club_id,
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active')
) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, 'local', %s, 'active')
RETURNING id""",
(
mime,
@ -776,11 +907,16 @@ def _ingest_library_media_file(
vis,
next_cid,
profile_id,
clean_cr,
storage_key,
),
)
ar = cur.fetchone()
aid = int(r2d(ar)["id"])
# P-06: Erklaerung schreiben und Schnellfelder setzen
if decl is not None:
write_rights_declaration(cur, aid, tenant.profile_id, "upload", vis, decl)
update_rights_quick_fields(cur, aid, vis)
return {"status": "created", "media_asset_id": aid, "original_filename": filename or storage_key}
@ -790,8 +926,27 @@ async def bulk_upload_media_assets(
files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"),
visibility: str = Form("private"),
club_id: Optional[int] = Form(None),
copyright_notice: Optional[str] = Form(None),
# P-06 Rechterklaerung (gilt fuer alle Dateien des Batches)
rights_holder_confirmed: bool = Form(...),
contains_identifiable_persons: bool = Form(...),
person_consent_confirmed: Optional[bool] = Form(None),
person_consent_context: Optional[str] = Form(None),
contains_minors: bool = Form(...),
parental_consent_confirmed: Optional[bool] = Form(None),
parental_consent_context: Optional[str] = Form(None),
contains_music: bool = Form(...),
music_rights_confirmed: Optional[bool] = Form(None),
music_rights_context: Optional[str] = Form(None),
contains_third_party_content: bool = Form(...),
third_party_rights_confirmed: Optional[bool] = Form(None),
third_party_rights_context: Optional[str] = Form(None),
):
"""Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion."""
"""Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion.
P-06: Eine Rechterklaerung gilt fuer alle Dateien des Batches.
VORLAEUTIG: Texte und Pflichtfelder noch nicht juristisch geprueft (p06-v1-conservative).
"""
if not files:
raise HTTPException(status_code=400, detail="Keine Dateien übermittelt")
if len(files) > _MAX_BULK_LIBRARY_FILES:
@ -800,6 +955,24 @@ async def bulk_upload_media_assets(
detail=f"Maximal {_MAX_BULK_LIBRARY_FILES} Dateien pro Anfrage",
)
decl = {
"rights_holder_confirmed": rights_holder_confirmed,
"contains_identifiable_persons": contains_identifiable_persons,
"person_consent_confirmed": person_consent_confirmed,
"person_consent_context": person_consent_context,
"contains_minors": contains_minors,
"parental_consent_confirmed": parental_consent_confirmed,
"parental_consent_context": parental_consent_context,
"contains_music": contains_music,
"music_rights_confirmed": music_rights_confirmed,
"music_rights_context": music_rights_context,
"contains_third_party_content": contains_third_party_content,
"third_party_rights_confirmed": third_party_rights_confirmed,
"third_party_rights_context": third_party_rights_context,
}
target_vis = (visibility or "private").strip().lower()
validate_rights_declaration(decl, target_vis)
results: list[dict[str, Any]] = []
created = duplicate = failed = 0
@ -821,7 +994,10 @@ async def bulk_upload_media_assets(
uf.content_type,
visibility,
club_id,
decl=decl,
copyright_notice=copyright_notice,
)
conn.commit()
results.append({"filename": fn, "ok": True, **r})
if r["status"] == "created":
created += 1
@ -969,7 +1145,10 @@ def list_media_assets(
cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
ma.copyright_notice, ma.storage_key, {tags_select}
ma.copyright_notice, ma.storage_key, ma.rights_status,
ma.legal_hold_active, ma.legal_hold_reason_code, ma.legal_hold_reason_note,
ma.legal_hold_set_at, ma.legal_hold_set_by_profile_id,
{tags_select}
pr.name AS uploader_name,
pr.email AS uploader_email,
cl.name AS club_name
@ -989,10 +1168,26 @@ def list_media_assets(
show_club = sup or is_adm or bool(admin_club_ids)
asset_ids = [int(r["id"]) for r in rows]
usage_map = _usage_for_media_assets(cur, asset_ids)
# open_report_count: nur für Admins relevant; immer befüllen (0 für normale Nutzer ok)
report_count_map: dict[int, int] = {}
if asset_ids and (is_adm or bool(admin_club_ids)):
cur.execute(
"""SELECT target_id, COUNT(*) AS cnt
FROM content_reports
WHERE target_type = 'media_asset'
AND target_id = ANY(%s)
AND status IN ('submitted', 'under_review')
GROUP BY target_id""",
(asset_ids,),
)
for row in cur.fetchall():
rd = r2d(row)
report_count_map[int(rd["target_id"])] = int(rd["cnt"])
for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
tid = int(r["id"])
r["usage"] = usage_map.get(tid, {"exercises": [], "training_units": []})
r["open_report_count"] = report_count_map.get(tid, 0)
tags_val = r.get("tags")
if tags_val is None:
r["tags"] = []
@ -1035,9 +1230,30 @@ def download_media_asset_file(
with get_db() as conn:
cur = get_cursor(conn)
asset = _fetch_asset_file_row(cur, asset_id)
# _fetch_asset_file_row wird erweitert um legal_hold_active
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
storage_key, mime_type, original_filename, legal_hold_active
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
asset = r2d(row) if row else None
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
# P-11: Legal Hold blockiert normalen Dateizugriff (nicht für Superadmin)
if bool(asset.get("legal_hold_active")):
role = tenant.global_role if hasattr(tenant, "global_role") else None
if not is_superadmin(role):
raise HTTPException(
status_code=403,
detail={
"code": "LEGAL_HOLD_ACTIVE",
"message": "Dieses Medium ist sofort gesperrt und nicht verfügbar.",
},
)
lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc == "active":
_assert_can_view_archive_asset(cur, tenant, asset)
@ -1158,6 +1374,56 @@ def bulk_media_patch(
)
continue
if next_vis in ("club", "official"):
effective_copyright = (
patch_fields.get("copyright_notice") or asset.get("copyright_notice") or ""
)
if not str(effective_copyright).strip():
failed.append(
{
"id": asset_id,
"detail": (
"Für Vereins- oder offizielle Medien ist eine "
"Urheberrechtsangabe (copyright_notice) Pflicht."
),
}
)
continue
# P-06: Rechteprüfung bei Promotion
cur_vis = (asset.get("visibility") or "private").strip().lower()
bulk_p06_decl: Optional[dict] = None
bulk_p06_action: Optional[str] = None
if VISIBILITY_LEVELS.get(next_vis, 0) > VISIBILITY_LEVELS.get(cur_vis, 0):
coverage = check_rights_coverage(cur, asset_id, next_vis)
if coverage != "ok":
p06_fields = {
"rights_holder_confirmed": patch_fields.get("rights_holder_confirmed"),
"contains_identifiable_persons": patch_fields.get("contains_identifiable_persons"),
"person_consent_confirmed": patch_fields.get("person_consent_confirmed"),
"contains_minors": patch_fields.get("contains_minors"),
"parental_consent_confirmed": patch_fields.get("parental_consent_confirmed"),
"contains_music": patch_fields.get("contains_music"),
"music_rights_confirmed": patch_fields.get("music_rights_confirmed"),
"contains_third_party_content": patch_fields.get("contains_third_party_content"),
"third_party_rights_confirmed": patch_fields.get("third_party_rights_confirmed"),
}
if p06_fields.get("rights_holder_confirmed") is None:
code = "LEGACY_REDECLARATION_REQUIRED" if coverage == "legacy" else "RIGHTS_SCOPE_INSUFFICIENT"
failed.append({
"id": asset_id,
"detail": f"{code}: Rechterklaerung fuer '{next_vis}' erforderlich.",
})
continue
try:
validate_rights_declaration(p06_fields, next_vis)
except HTTPException as p06e:
d = p06e.detail
failed.append({"id": asset_id, "detail": d.get("message") if isinstance(d, dict) else str(d)})
continue
bulk_p06_decl = p06_fields
bulk_p06_action = "legacy_re_declaration" if coverage == "legacy" else "promote_club" if next_vis == "club" else "promote_official"
new_sk: Optional[str] = None
if "visibility" in patch_fields or "club_id" in patch_fields:
next_club_param: Optional[int] = None
@ -1196,6 +1462,12 @@ def bulk_media_patch(
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
# P-06: Declaration-Log schreiben wenn neue Erklaerung bei Promotion
if bulk_p06_decl is not None and bulk_p06_action is not None:
write_rights_declaration(
cur, asset_id, profile_id, bulk_p06_action, next_vis, bulk_p06_decl
)
update_rights_quick_fields(cur, asset_id, next_vis)
conn.commit()
updated.append(asset_id)
except HTTPException as he:
@ -1239,6 +1511,8 @@ def patch_media_asset(
eff = _effective_media_patch_fields(data, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
next_cid = eff.get("club_id", asset.get("club_id"))
_p06_pending_decl: Optional[dict] = None
_p06_action: Optional[str] = None
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(
cur,
@ -1256,6 +1530,75 @@ def patch_media_asset(
),
)
if next_vis in ("club", "official"):
effective_copyright = (
data.get("copyright_notice") or asset.get("copyright_notice") or ""
)
if not str(effective_copyright).strip():
raise HTTPException(
status_code=400,
detail=(
"Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe "
"(copyright_notice) Pflicht. Bitte vor oder zusammen mit der "
"Freigabe angeben."
),
)
# P-06: Rechteprüfung bei Sichtbarkeits-Promotion
cur_vis = (asset.get("visibility") or "private").strip().lower()
if VISIBILITY_LEVELS.get(next_vis, 0) > VISIBILITY_LEVELS.get(cur_vis, 0):
coverage = check_rights_coverage(cur, asset_id, next_vis)
if coverage != "ok":
# Neue Erklaerung muss im Body mitgeliefert werden
p06_fields = {
"rights_holder_confirmed": data.get("rights_holder_confirmed"),
"contains_identifiable_persons": data.get("contains_identifiable_persons"),
"person_consent_confirmed": data.get("person_consent_confirmed"),
"contains_minors": data.get("contains_minors"),
"parental_consent_confirmed": data.get("parental_consent_confirmed"),
"contains_music": data.get("contains_music"),
"music_rights_confirmed": data.get("music_rights_confirmed"),
"contains_third_party_content": data.get("contains_third_party_content"),
"third_party_rights_confirmed": data.get("third_party_rights_confirmed"),
}
if coverage == "legacy":
if p06_fields.get("rights_holder_confirmed") is None:
raise HTTPException(
status_code=400,
detail={
"code": "LEGACY_REDECLARATION_REQUIRED",
"message": (
"Dieses Medium wurde vor Einfuehrung der Einwilligungspflicht "
"hochgeladen. Bitte eine vollstaendige Rechterklaerung (P-06-Felder) "
"zusammen mit dem PATCH uebergeben."
),
"asset_id": asset_id,
},
)
else:
if p06_fields.get("rights_holder_confirmed") is None:
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_SCOPE_INSUFFICIENT",
"message": (
f"Die vorhandene Erklaerung deckt '{next_vis}' nicht ab. "
"Bitte eine neue Erklaerung (P-06-Felder) mitschicken."
),
"asset_id": asset_id,
"target_visibility": next_vis,
},
)
validate_rights_declaration(p06_fields, next_vis)
_p06_pending_decl = p06_fields
_p06_action = "legacy_re_declaration" if coverage == "legacy" else "promote_club" if next_vis == "club" else "promote_official"
else:
_p06_pending_decl = None
_p06_action = None
else:
_p06_pending_decl = None
_p06_action = None
new_sk: Optional[str] = None
if "visibility" in data or "club_id" in data:
next_club_param: Optional[int] = None
@ -1292,6 +1635,38 @@ def patch_media_asset(
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
# P-06: Declaration-Log schreiben wenn neue Erklaerung bei Promotion
if _p06_pending_decl is not None and _p06_action is not None:
write_rights_declaration(
cur, asset_id, profile_id, _p06_action, next_vis, _p06_pending_decl
)
update_rights_quick_fields(cur, asset_id, next_vis)
# Audit-Log: Änderungen protokollieren (gleiche Transaktion)
if "visibility" in data or "club_id" in data:
_old_vis = (asset.get("visibility") or "").strip()
_old_cid = asset.get("club_id")
if next_vis != _old_vis or next_cid != _old_cid:
write_audit_log_entry(
cur, asset_id, profile_id, "visibility_change",
{"visibility": _old_vis, "club_id": _old_cid},
{"visibility": next_vis, "club_id": next_cid},
)
if "copyright_notice" in data:
_old_cr = (asset.get("copyright_notice") or "").strip()
_new_cr = (data.get("copyright_notice") or "").strip()
if _new_cr != _old_cr:
write_audit_log_entry(
cur, asset_id, profile_id, "copyright_change",
{"copyright_notice": asset.get("copyright_notice")},
{"copyright_notice": data["copyright_notice"]},
)
_meta_old: dict = {}
_meta_new: dict = {}
if "original_filename" in data:
_meta_old["original_filename"] = asset.get("original_filename")
_meta_new["original_filename"] = data["original_filename"]
if _meta_new:
write_audit_log_entry(cur, asset_id, profile_id, "metadata_change", _meta_old, _meta_new)
conn.commit()
has_tags = _media_assets_tags_column_present(cur)
if has_tags:
@ -1312,3 +1687,410 @@ def patch_media_asset(
if not has_tags:
out["tags"] = []
return out
# ---------------------------------------------------------------------------
# P-06: Re-Deklaration / Nachdeklaration
# ---------------------------------------------------------------------------
@router.post("/{asset_id}/rights-declarations")
def create_rights_declaration(
asset_id: int,
body: RightsDeclarationBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-06: Explizite Rechte-Erklaerung fuer ein bestehendes Medium.
Verwendung:
- Altmedium ('legacy_unreviewed'): Erste Erklaerung nach P-06 (action_type='legacy_re_declaration')
- Medium mit Erklaerung fuer niedrigere Sichtbarkeit: Neue Erklaerung fuer Zielsichtbarkeit
(action_type='re_declaration')
Die Erklaerung aendert NICHT die Sichtbarkeit des Mediums; dafuer PATCH verwenden.
"""
profile_id = tenant.profile_id
decl = body.model_dump() if hasattr(body, "model_dump") else body.dict()
target_vis = decl.pop("target_visibility")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, uploaded_by_profile_id, lifecycle_state, rights_status FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
assert_can_edit_media_asset_metadata(cur, tenant, asset)
validate_rights_declaration(decl, target_vis)
rs = (asset.get("rights_status") or "legacy_unreviewed").strip().lower()
action_type = "legacy_re_declaration" if rs == "legacy_unreviewed" else "re_declaration"
decl_id = write_rights_declaration(cur, asset_id, profile_id, action_type, target_vis, decl)
update_rights_quick_fields(cur, asset_id, target_vis)
conn.commit()
return {
"declaration_id": decl_id,
"asset_id": asset_id,
"action_type": action_type,
"target_visibility": target_vis,
"rights_status": "declared",
}
# ---------------------------------------------------------------------------
# P-06: Admin Legacy-Übersicht
# ---------------------------------------------------------------------------
admin_rights_router = APIRouter(prefix="/api/admin/media-rights", tags=["admin", "media-rights"])
@admin_rights_router.get("/legacy-summary")
def get_legacy_rights_summary(
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-06 Admin: Zusammenfassung wie viele Medien noch im legacy_unreviewed-Status sind."""
role = tenant.global_role
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung (Plattform-Admin erforderlich)")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT rights_status, COUNT(*) AS cnt
FROM media_assets
WHERE lifecycle_state = 'active'
GROUP BY rights_status
ORDER BY rights_status
"""
)
rows = [r2d(r) for r in cur.fetchall()]
totals = {r["rights_status"]: int(r["cnt"]) for r in rows}
total_active = sum(totals.values())
cur.execute(
"SELECT COUNT(*) AS cnt FROM media_asset_rights_declarations"
)
decl_count_row = cur.fetchone()
total_declarations = int(r2d(decl_count_row)["cnt"]) if decl_count_row else 0
return {
"total_active_assets": total_active,
"legacy_unreviewed": totals.get("legacy_unreviewed", 0),
"declared": totals.get("declared", 0),
"blocked": totals.get("blocked", 0),
"total_declarations_logged": total_declarations,
}
@admin_rights_router.get("/legacy-assets")
def get_legacy_rights_assets(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
visibility: Optional[str] = Query(None, pattern="^(private|club|official)$"),
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-06 Admin: Liste der Medien mit rights_status = 'legacy_unreviewed' oder 'blocked'."""
role = tenant.global_role
if not is_platform_admin(role):
raise HTTPException(status_code=403, detail="Keine Berechtigung (Plattform-Admin erforderlich)")
with get_db() as conn:
cur = get_cursor(conn)
conditions = ["ma.lifecycle_state = 'active'", "ma.rights_status != 'declared'"]
params: list[Any] = []
if visibility:
conditions.append("ma.visibility = %s")
params.append(visibility)
where = " AND ".join(conditions)
cur.execute(
f"""
SELECT ma.id, ma.original_filename, ma.visibility, ma.rights_status,
ma.created_at, ma.uploaded_by_profile_id,
p.name AS uploader_name
FROM media_assets ma
LEFT JOIN profiles p ON p.id = ma.uploaded_by_profile_id
WHERE {where}
ORDER BY ma.created_at DESC
LIMIT %s OFFSET %s
""",
(*params, limit, offset),
)
assets = [r2d(r) for r in cur.fetchall()]
cur.execute(
f"""
SELECT COUNT(*) AS cnt
FROM media_assets ma
WHERE {where}
""",
tuple(params),
)
total_row = cur.fetchone()
total = int(r2d(total_row)["cnt"]) if total_row else 0
return {"total": total, "limit": limit, "offset": offset, "assets": assets}
@admin_rights_router.get("/assets/{asset_id}/journal")
def get_media_asset_journal(
asset_id: int,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Vollständiges Medien-Journal: Einwilligungen + alle Änderungen chronologisch.
Zugriff: Superadmin, ursprünglicher Uploader oder Vereins-Admin des betreffenden Mediums.
"""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT id, original_filename, visibility, club_id, rights_status,
rights_declared_for_visibility, rights_declared_at,
copyright_notice, mime_type, lifecycle_state,
uploaded_by_profile_id, created_at
FROM media_assets
WHERE id = %s
""",
(asset_id,),
)
asset_row = cur.fetchone()
if not asset_row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(asset_row)
is_sup = is_superadmin(role)
is_uploader = asset.get("uploaded_by_profile_id") == profile_id
is_club_adm = False
if asset.get("club_id"):
is_club_adm = has_club_role(cur, profile_id, asset["club_id"], "club_admin")
if not (is_sup or is_uploader or is_club_adm):
raise HTTPException(status_code=403, detail="Keine Berechtigung (Superadmin, Uploader oder Vereinsadmin)")
can_correct = is_sup or is_uploader or is_club_adm
cur.execute(
"""
SELECT d.id, d.declared_at, d.action_type, d.target_visibility,
d.declaration_version,
d.rights_holder_confirmed,
d.contains_identifiable_persons, d.person_consent_confirmed,
d.person_consent_context,
d.contains_minors, d.parental_consent_confirmed,
d.parental_consent_context,
d.contains_music, d.music_rights_confirmed,
d.music_rights_context,
d.contains_third_party_content, d.third_party_rights_confirmed,
d.third_party_rights_context,
d.declared_by_profile_id, d.correction_note,
p.name AS declared_by_name,
p.email AS declared_by_email
FROM media_asset_rights_declarations d
LEFT JOIN profiles p ON p.id = d.declared_by_profile_id
WHERE d.media_asset_id = %s
ORDER BY d.declared_at ASC
""",
(asset_id,),
)
events: list[dict] = []
for r in cur.fetchall():
d = r2d(r)
d["kind"] = "declaration"
d["at"] = str(d.get("declared_at", ""))
events.append(d)
# Audit-Log (Migration 050 Tabelle darf noch nicht existieren)
try:
cur.execute(
"""
SELECT a.id, a.occurred_at, a.event_type, a.old_values, a.new_values,
a.acting_profile_id,
p.name AS acting_name,
p.email AS acting_email
FROM media_asset_audit_log a
LEFT JOIN profiles p ON p.id = a.acting_profile_id
WHERE a.media_asset_id = %s
ORDER BY a.occurred_at ASC
""",
(asset_id,),
)
for r in cur.fetchall():
a = r2d(r)
a["kind"] = "audit"
a["at"] = str(a.get("occurred_at", ""))
events.append(a)
except Exception:
pass # Tabelle noch nicht migriert
events.sort(key=lambda e: e.get("at", ""))
return {"asset": asset, "events": events, "can_correct": can_correct}
@admin_rights_router.post("/assets/{asset_id}/correction")
def add_rights_correction(
asset_id: int,
body: RightsCorrectionBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Nachträgliche Korrektur einer P-06-Einwilligungserklärung (append-only, neueste gilt).
Zugriff: Superadmin, ursprünglicher Uploader oder Vereins-Admin.
"""
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"SELECT id, uploaded_by_profile_id, club_id, rights_status, lifecycle_state FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
is_sup = is_superadmin(role)
is_uploader = asset.get("uploaded_by_profile_id") == profile_id
is_club_adm = False
if asset.get("club_id"):
is_club_adm = has_club_role(cur, profile_id, asset["club_id"], "club_admin")
if not (is_sup or is_uploader or is_club_adm):
raise HTTPException(
status_code=403,
detail="Keine Berechtigung (Superadmin, ursprünglicher Uploader oder Vereinsadmin erforderlich)",
)
decl = body.model_dump(exclude_unset=False) if hasattr(body, "model_dump") else body.dict()
target_vis = decl.pop("target_visibility")
correction_note = decl.pop("correction_note", None)
validate_rights_declaration(decl, target_vis)
write_rights_correction_declaration(cur, asset_id, profile_id, target_vis, decl, correction_note)
update_rights_quick_fields(cur, asset_id, target_vis)
conn.commit()
return {"ok": True, "asset_id": asset_id}
# ---------------------------------------------------------------------------
# P-11: Legal-Hold-Endpoints (Superadmin only)
# ---------------------------------------------------------------------------
admin_legal_hold_router = APIRouter(prefix="/api/admin/media-assets", tags=["admin", "legal-hold"])
class LegalHoldSetBody(BaseModel):
reason_code: str = Field(..., description="Kategorie des Legal Holds")
reason_note: str = Field(..., min_length=5, max_length=2000, description="Pflichtbegründung")
class LegalHoldReleaseBody(BaseModel):
release_note: str = Field(..., min_length=5, max_length=2000, description="Freigabe-Begründung")
@admin_legal_hold_router.post("/{asset_id}/legal-hold")
def post_legal_hold_set(
asset_id: int,
body: LegalHoldSetBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-11: Legal Hold auf ein Medium setzen. Nur Superadmin.
Wirkung: Sofortige Unsichtbarkeit in normalen Nutzerpfaden, rights_status='blocked',
Audit-Log-Eintrag. Keine automatische physische Löschung. Keine Löschfristen.
"""
assert_superadmin_for_legal_hold(tenant.global_role)
with get_db() as conn:
cur = get_cursor(conn)
result = set_legal_hold(
cur, conn,
asset_id=asset_id,
acting_profile_id=int(tenant.profile_id),
reason_code=body.reason_code,
reason_note=body.reason_note,
)
return {
"ok": True,
"asset_id": asset_id,
"legal_hold_active": result.get("legal_hold_active"),
"rights_status": result.get("rights_status"),
"legal_hold_reason_code": result.get("legal_hold_reason_code"),
"legal_hold_set_at": str(result.get("legal_hold_set_at", "")),
}
@admin_legal_hold_router.post("/{asset_id}/legal-hold/release")
def post_legal_hold_release(
asset_id: int,
body: LegalHoldReleaseBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-11: Legal Hold aufheben. Nur Superadmin.
Wirkung: legal_hold_active=FALSE, rights_status wiederhergestellt,
Audit-Log-Eintrag. Vorheriger Lifecycle-Zustand bleibt erhalten.
"""
assert_superadmin_for_legal_hold(tenant.global_role)
with get_db() as conn:
cur = get_cursor(conn)
result = release_legal_hold(
cur, conn,
asset_id=asset_id,
acting_profile_id=int(tenant.profile_id),
release_note=body.release_note,
)
return {
"ok": True,
"asset_id": asset_id,
"legal_hold_active": result.get("legal_hold_active"),
"rights_status": result.get("rights_status"),
"legal_hold_released_at": str(result.get("legal_hold_released_at", "")),
}
@admin_legal_hold_router.get("/legal-hold")
def list_legal_hold_assets(
limit: int = Query(100, ge=1, le=500),
offset: int = Query(0, ge=0),
tenant: TenantContext = Depends(get_tenant_context),
):
"""P-11: Admin-Liste aller Medien unter aktivem Legal Hold. Superadmin oder Plattform-Admin."""
if not is_platform_admin(tenant.global_role):
raise HTTPException(
status_code=403,
detail="Keine Berechtigung (Plattform-Admin oder Superadmin erforderlich)",
)
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""
SELECT ma.id, ma.original_filename, ma.visibility, ma.club_id,
ma.lifecycle_state, ma.rights_status,
ma.legal_hold_active, ma.legal_hold_reason_code,
ma.legal_hold_reason_note, ma.legal_hold_set_at,
ma.legal_hold_set_by_profile_id,
p_set.name AS set_by_name,
ma.uploaded_by_profile_id,
p_upl.name AS uploader_name,
cl.name AS club_name,
ma.created_at
FROM media_assets ma
LEFT JOIN profiles p_set ON p_set.id = ma.legal_hold_set_by_profile_id
LEFT JOIN profiles p_upl ON p_upl.id = ma.uploaded_by_profile_id
LEFT JOIN clubs cl ON cl.id = ma.club_id
WHERE ma.legal_hold_active = TRUE
ORDER BY ma.legal_hold_set_at DESC NULLS LAST, ma.id DESC
LIMIT %s OFFSET %s
""",
(limit, offset),
)
assets = [r2d(r) for r in cur.fetchall()]
cur.execute(
"SELECT COUNT(*) AS cnt FROM media_assets WHERE legal_hold_active = TRUE"
)
total_row = cur.fetchone()
total = int(r2d(total_row)["cnt"]) if total_row else 0
return {"total": total, "limit": limit, "offset": offset, "assets": assets}

View File

@ -22,6 +22,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
"auth.py",
"admin_users.py",
"platform_media_storage.py",
"legal_documents.py", # ACCESS_LAYER exempt: Plattform-Rechtstexte ohne Vereinsbezug; öffentlicher Endpoint ohne Auth, Admin-Endpoints require_auth + is_superadmin()
"catalogs.py",
"skills.py",
"maturity_models.py",

View File

@ -0,0 +1,100 @@
"""
P-05b: POST /api/auth/reset-password muss Passwoerter unter 8 Zeichen ablehnen.
Prueft Pydantic-Validierung (HTTP 422) BEVOR die DB befragt wird.
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _mock_db_valid_token() -> MagicMock:
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {"profile_id": 1}
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm, mock_cur
# ── Mindestlaenge-Grenzwerte ──────────────────────────────────────────────────
@pytest.mark.parametrize("short_pw", ["", "a", "1234567"])
def test_reset_password_too_short_rejected(client: TestClient, short_pw: str) -> None:
"""Passwort < 8 Zeichen muss mit 422 abgelehnt werden (Pydantic-Validierung)."""
r = client.post(
"/api/auth/reset-password",
json={"token": "sometoken", "new_password": short_pw},
)
assert r.status_code == 422, (
f"Erwartet 422 fuer Passwort {short_pw!r} ({len(short_pw)} Zeichen), "
f"erhalten {r.status_code}"
)
def test_reset_password_exactly_8_chars_accepted(client: TestClient) -> None:
"""Passwort mit genau 8 Zeichen muss die Validierung passieren."""
mock_cm, mock_cur = _mock_db_valid_token()
with patch("routers.auth.get_db", return_value=mock_cm), patch(
"routers.auth.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/auth/reset-password",
json={"token": "sometoken", "new_password": "12345678"},
)
assert r.status_code == 200
assert r.json().get("ok") is True
def test_reset_password_long_password_accepted(client: TestClient) -> None:
"""Langes Passwort (>= 8 Zeichen) muss akzeptiert werden."""
mock_cm, mock_cur = _mock_db_valid_token()
with patch("routers.auth.get_db", return_value=mock_cm), patch(
"routers.auth.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/auth/reset-password",
json={"token": "sometoken", "new_password": "sicheresPasswort123!"},
)
assert r.status_code == 200
def test_reset_password_missing_field_rejected(client: TestClient) -> None:
"""Fehlendes new_password-Feld muss mit 422 abgelehnt werden."""
r = client.post(
"/api/auth/reset-password",
json={"token": "sometoken"},
)
assert r.status_code == 422
def test_reset_password_invalid_token_returns_400(client: TestClient) -> None:
"""Gueltiges Passwort aber ungueltiger Token muss 400 liefern."""
mock_cur = MagicMock()
mock_cur.fetchone.return_value = None
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with patch("routers.auth.get_db", return_value=mock_cm), patch(
"routers.auth.get_cursor", return_value=mock_cur
):
r = client.post(
"/api/auth/reset-password",
json={"token": "ungueltig", "new_password": "validesPw123"},
)
assert r.status_code == 400

View File

@ -130,8 +130,8 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{"created_by": 1, "visibility": "private", "club_id": None}, # _assert_can_edit_exercise
{"c": 0}, # _count_exercise_media
{
"id": 5,
"mime_type": "image/jpeg",
@ -142,14 +142,15 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": _SK_OFF_A,
},
{"id": 1},
}, # asset lookup
{"visibility": "private"}, # P-06: exercise visibility
{"id": 1}, # duplicate check -> 400
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
), patch("routers.exercises.assert_rights_for_exercise_link"):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
@ -196,8 +197,8 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
}
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{"created_by": 1, "visibility": "private", "club_id": None},
{"c": 0},
{"created_by": 1, "visibility": "private", "club_id": None}, # _assert_can_edit_exercise
{"c": 0}, # _count_exercise_media
{
"id": 5,
"mime_type": "image/jpeg",
@ -208,15 +209,16 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": _SK_OFF_B,
},
None,
inserted,
}, # asset lookup
{"visibility": "private"}, # P-06: exercise visibility
None, # duplicate check -> None (no duplicate)
inserted, # INSERT RETURNING
]
mock_cm = _mock_db(mock_cur)
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
"routers.exercises.get_cursor", return_value=mock_cur
):
), patch("routers.exercises.assert_rights_for_exercise_link"):
r = client.post(
"/api/exercises/3/media/from-asset",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},

View File

@ -0,0 +1,258 @@
"""
P-04: Copyright-Pflicht bei Promotion auf club/official.
PATCH /api/media-assets/{id} und POST /api/media-assets/bulk-patch
lehnen eine Sichtbarkeits-Promotion auf club oder official ab,
wenn keine copyright_notice vorhanden ist.
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
from tenant_context import TenantContext, get_tenant_context
_SUPERADMIN_TENANT = TenantContext(
profile_id=1,
global_role="superadmin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
_PRIVATE_ASSET: dict = {
"id": 42,
"visibility": "private",
"club_id": 7,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"copyright_notice": None,
"original_filename": "foto.jpg",
"sha256": "a" * 64,
"storage_key": f"library/verein-7/image/{'a' * 64}.jpg",
"storage_backend": "local",
"mime_type": "image/jpeg",
"byte_size": 1024,
"created_at": None,
"tags": [],
}
_ASSET_WITH_COPYRIGHT: dict = {**_PRIVATE_ASSET, "copyright_notice": "Verein 2026"}
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(get_tenant_context, None)
def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]:
mock_cur = MagicMock()
mock_cur.fetchone.return_value = asset
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm, mock_cur
_PERMISSION_PATCHES = [
("routers.media_assets.assert_can_edit_media_asset_metadata", {}),
("routers.media_assets.assert_valid_governance_visibility", {}),
("routers.media_assets._media_assets_tags_column_present", {"return_value": False}),
("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}),
("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}),
# P-06: bestehende Tests testen Copyright, nicht Rechteerklaerung "ok" mocken
("routers.media_assets.check_rights_coverage", {"return_value": "ok"}),
]
def _enter_permission_patches(stack: ExitStack) -> None:
for target, kwargs in _PERMISSION_PATCHES:
stack.enter_context(patch(target, **kwargs))
# ── Single PATCH ─────────────────────────────────────────────────────────────
def test_patch_promote_to_club_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> club ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_official_without_copyright_returns_400(client: TestClient) -> None:
"""Promotion private -> official ohne copyright_notice muss 400 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "official"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
detail = r.json()["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_patch_promote_to_club_with_copyright_in_body_allowed(client: TestClient) -> None:
"""Promotion private -> club MIT copyright_notice im Body muss 200 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
updated_asset = {**_PRIVATE_ASSET, "visibility": "club", "copyright_notice": "Verein 2026"}
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, updated_asset]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7, "copyright_notice": "Verein 2026"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["id"] == 42
assert body["visibility"] == "club"
assert body["copyright_notice"] == "Verein 2026"
def test_patch_promote_to_club_existing_copyright_allowed(client: TestClient) -> None:
"""Asset hat bereits copyright_notice -> Promotion ohne Body-Copyright muss 200 liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
promoted_asset = {**_ASSET_WITH_COPYRIGHT, "visibility": "club"}
mock_cm, mock_cur = _make_db_mocks(_ASSET_WITH_COPYRIGHT)
mock_cur.fetchone.side_effect = [_ASSET_WITH_COPYRIGHT, promoted_asset]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["id"] == 42
assert body["visibility"] == "club"
assert body["copyright_notice"] == "Verein 2026"
def test_patch_filename_only_no_copyright_check(client: TestClient) -> None:
"""Kein Visibility-Wechsel -> keine Copyright-Prufung."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{**_PRIVATE_ASSET, "original_filename": "neu.jpg"},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"original_filename": "neu.jpg"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
# ── Bulk PATCH ────────────────────────────────────────────────────────────────
def test_bulk_patch_promote_to_club_without_copyright_in_failed(client: TestClient) -> None:
"""Bulk-Promotion ohne copyright_notice -> Asset in failed-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={"media_asset_ids": [42], "visibility": "club", "club_id": 7},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert body["updated_count"] == 0
assert body["failed_count"] == 1
detail = body["failed"][0]["detail"].lower()
assert "copyright" in detail or "urheberrecht" in detail
def test_bulk_patch_promote_to_club_with_copyright_in_updated(client: TestClient) -> None:
"""Bulk-Promotion MIT copyright_notice -> Asset in updated-Liste."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cur = MagicMock()
mock_cur.fetchone.return_value = _PRIVATE_ASSET
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.post(
"/api/media-assets/bulk-patch",
json={
"media_asset_ids": [42],
"visibility": "club",
"club_id": 7,
"copyright_notice": "Verein 2026",
},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
body = r.json()
assert 42 in body["updated"]
assert body["updated_count"] == 1

View File

@ -0,0 +1,430 @@
"""
P-06: Rechte-Erklaerung Backend-Tests.
Abgedeckt:
1. validate_rights_declaration alle Pflichtfelder
2. check_rights_coverage ok / insufficient / legacy / blocked
3. assert_rights_for_promotion richtiges Fehlermuster
4. PATCH /api/media-assets/{id} Promotion mit und ohne P-06
5. POST /api/media-assets/{id}/rights-declarations Re-Deklaration
6. POST /api/media-assets/bulk-patch P-06-Pfad im Bulk
"""
from __future__ import annotations
import os
from contextlib import ExitStack
from unittest.mock import MagicMock, patch, call
import pytest
from fastapi.testclient import TestClient
from fastapi import HTTPException
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from main import app
from media_rights import (
validate_rights_declaration,
check_rights_coverage,
assert_rights_for_promotion,
rights_covers_target,
visibility_level,
)
from tenant_context import TenantContext, get_tenant_context
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
_SUPERADMIN_TENANT = TenantContext(
profile_id=1,
global_role="superadmin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
_FULL_DECL = {
"rights_holder_confirmed": True,
"contains_identifiable_persons": False,
"person_consent_confirmed": None,
"contains_minors": False,
"parental_consent_confirmed": None,
"contains_music": False,
"music_rights_confirmed": None,
"contains_third_party_content": False,
"third_party_rights_confirmed": None,
}
_PRIVATE_ASSET = {
"id": 42,
"visibility": "private",
"club_id": 7,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"copyright_notice": "Rechteinhaber 2026",
"original_filename": "foto.jpg",
"sha256": "a" * 64,
"storage_key": f"library/verein-7/image/{'a' * 64}.jpg",
"storage_backend": "local",
"mime_type": "image/jpeg",
"byte_size": 1024,
"created_at": None,
"tags": [],
"rights_status": "legacy_unreviewed",
"rights_declared_for_visibility": None,
}
_DECLARED_ASSET = {**_PRIVATE_ASSET, "rights_status": "declared", "rights_declared_for_visibility": "private"}
_BLOCKED_ASSET = {**_PRIVATE_ASSET, "rights_status": "blocked", "rights_declared_for_visibility": None}
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(get_tenant_context, None)
def _make_db_mocks(asset: dict) -> tuple[MagicMock, MagicMock]:
mock_cur = MagicMock()
mock_cur.fetchone.return_value = asset
mock_conn = MagicMock()
mock_cm = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
return mock_cm, mock_cur
_PERMISSION_PATCHES = [
("routers.media_assets.assert_can_edit_media_asset_metadata", {}),
("routers.media_assets.assert_valid_governance_visibility", {}),
("routers.media_assets._media_assets_tags_column_present", {"return_value": False}),
("routers.media_assets.get_effective_media_root", {"return_value": "/tmp/media"}),
("routers.media_assets._relocate_asset_file_if_governance_changed", {"return_value": None}),
]
def _enter_permission_patches(stack: ExitStack) -> None:
for target, kwargs in _PERMISSION_PATCHES:
stack.enter_context(patch(target, **kwargs))
# ===========================================================================
# 1. validate_rights_declaration Unit-Tests (kein HTTP)
# ===========================================================================
class TestValidateRightsDeclaration:
def test_missing_rights_holder_raises(self):
decl = {**_FULL_DECL, "rights_holder_confirmed": False}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.status_code == 400
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
def test_identifiable_persons_none_raises(self):
decl = {**_FULL_DECL, "contains_identifiable_persons": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.status_code == 400
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
def test_person_consent_required_when_persons_present(self):
decl = {**_FULL_DECL, "contains_identifiable_persons": True, "person_consent_confirmed": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "club")
assert exc.value.detail["code"] == "PERSON_CONSENT_REQUIRED"
def test_minors_none_raises(self):
decl = {**_FULL_DECL, "contains_minors": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
def test_parental_consent_required_when_minors_present(self):
decl = {**_FULL_DECL, "contains_minors": True, "parental_consent_confirmed": False}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "official")
assert exc.value.detail["code"] == "PARENTAL_CONSENT_REQUIRED"
def test_music_none_raises(self):
decl = {**_FULL_DECL, "contains_music": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
def test_music_rights_required_when_music_present(self):
decl = {**_FULL_DECL, "contains_music": True, "music_rights_confirmed": False}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.detail["code"] == "MUSIC_RIGHTS_REQUIRED"
def test_third_party_none_raises(self):
decl = {**_FULL_DECL, "contains_third_party_content": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "private")
assert exc.value.detail["code"] == "RIGHTS_DECLARATION_REQUIRED"
def test_third_party_rights_required_when_content_present(self):
decl = {**_FULL_DECL, "contains_third_party_content": True, "third_party_rights_confirmed": None}
with pytest.raises(HTTPException) as exc:
validate_rights_declaration(decl, "official")
assert exc.value.detail["code"] == "THIRD_PARTY_RIGHTS_REQUIRED"
def test_full_clean_decl_passes(self):
validate_rights_declaration(_FULL_DECL, "official")
def test_full_decl_with_all_true_passes(self):
decl = {
"rights_holder_confirmed": True,
"contains_identifiable_persons": True,
"person_consent_confirmed": True,
"contains_minors": True,
"parental_consent_confirmed": True,
"contains_music": True,
"music_rights_confirmed": True,
"contains_third_party_content": True,
"third_party_rights_confirmed": True,
}
validate_rights_declaration(decl, "official")
def test_private_also_requires_full_declaration(self):
"""Konservative Erstannahme: private erfordert dieselbe Erklaerung wie official."""
decl = {**_FULL_DECL, "contains_identifiable_persons": None}
with pytest.raises(HTTPException):
validate_rights_declaration(decl, "private")
# ===========================================================================
# 2. check_rights_coverage Unit-Tests mit Mock-Cursor
# ===========================================================================
class TestCheckRightsCoverage:
def _cur(self, row):
cur = MagicMock()
cur.fetchone.return_value = row
return cur
def test_no_asset_returns_no_declaration(self):
cur = self._cur(None)
assert check_rights_coverage(cur, 1, "private") == "no_declaration"
def test_blocked_returns_blocked(self):
cur = self._cur({"rights_status": "blocked"})
assert check_rights_coverage(cur, 1, "private") == "blocked"
def test_legacy_returns_legacy(self):
cur = self._cur({"rights_status": "legacy_unreviewed"})
assert check_rights_coverage(cur, 1, "club") == "legacy"
def test_declared_covers_any_visibility(self):
# Eine bestehende Erklaerung gilt sichtbarkeitsunabhaengig
for target in ("private", "club", "official"):
cur = self._cur({"rights_status": "declared"})
assert check_rights_coverage(cur, 1, target) == "ok", f"failed for target={target}"
# ===========================================================================
# 3. assert_rights_for_promotion Fehlermuster
# ===========================================================================
class TestAssertRightsForPromotion:
def _cur(self, row):
cur = MagicMock()
cur.fetchone.return_value = row
return cur
def test_ok_passes_for_any_target(self):
for target in ("private", "club", "official"):
cur = self._cur({"rights_status": "declared"})
assert_rights_for_promotion(cur, 1, target) # no raise
def test_legacy_raises_legacy_code(self):
cur = self._cur({"rights_status": "legacy_unreviewed"})
with pytest.raises(HTTPException) as exc:
assert_rights_for_promotion(cur, 1, "club")
assert exc.value.status_code == 400
assert exc.value.detail["code"] == "LEGACY_REDECLARATION_REQUIRED"
def test_blocked_raises_403(self):
cur = self._cur({"rights_status": "blocked"})
with pytest.raises(HTTPException) as exc:
assert_rights_for_promotion(cur, 1, "official")
assert exc.value.status_code == 403
assert exc.value.detail["code"] == "RIGHTS_BLOCKED"
# ===========================================================================
# 4. PATCH /api/media-assets/{id} P-06-Promotion via HTTP
# ===========================================================================
class TestPatchP06Promotion:
def test_promote_legacy_without_decl_returns_400(self, client):
"""PATCH private->club ohne P-06-Felder muss LEGACY_REDECLARATION_REQUIRED liefern."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [_PRIVATE_ASSET, {"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None}]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
r = client.patch(
"/api/media-assets/42",
json={"visibility": "club", "club_id": 7, "copyright_notice": "Rechteinhaber 2026"},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 400
d = r.json()["detail"]
assert d["code"] == "LEGACY_REDECLARATION_REQUIRED"
def test_promote_legacy_with_full_decl_calls_write_declaration(self, client):
"""PATCH private->club mit vollstaendiger P-06-Erklaerung schreibt Declaration-Log."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
updated_asset = {**_PRIVATE_ASSET, "visibility": "club"}
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None},
updated_asset,
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
_enter_permission_patches(stack)
wr = stack.enter_context(patch("routers.media_assets.write_rights_declaration", return_value=1))
uq = stack.enter_context(patch("routers.media_assets.update_rights_quick_fields"))
r = client.patch(
"/api/media-assets/42",
json={
"visibility": "club",
"club_id": 7,
"copyright_notice": "Rechteinhaber 2026",
**_FULL_DECL,
},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
assert wr.called
call_args = wr.call_args
assert call_args.args[3] == "legacy_re_declaration"
assert call_args.args[4] == "club"
assert uq.called
# ===========================================================================
# 5. POST /api/media-assets/{id}/rights-declarations Re-Deklaration
# ===========================================================================
class TestPostRightsDeclaration:
def test_redeclaration_for_legacy_asset_succeeds(self, client):
"""Nachdeklaration fuer Altmedium setzt action_type='legacy_re_declaration'."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.return_value = _PRIVATE_ASSET
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
wr = stack.enter_context(patch("routers.media_assets.write_rights_declaration", return_value=99))
stack.enter_context(patch("routers.media_assets.update_rights_quick_fields"))
r = client.post(
"/api/media-assets/42/rights-declarations",
json={"target_visibility": "club", **_FULL_DECL},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
data = r.json()
assert data["action_type"] == "legacy_re_declaration"
assert data["declaration_id"] == 99
assert wr.called
def test_redeclaration_incomplete_decl_returns_400_or_422(self, client):
"""Fehlende Erklaerungsfelder fuehren zu 400 (business logic) oder 422 (Pydantic)."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
r = client.post(
"/api/media-assets/42/rights-declarations",
# Fehlende Pflichtfelder (contains_identifiable_persons etc.) -> Pydantic 422 ODER
# validate_rights_declaration 400 je nach welche Felder fehlen
json={"target_visibility": "private", "rights_holder_confirmed": True},
headers={"X-Auth-Token": "t"},
)
assert r.status_code in (400, 422)
def test_redeclaration_asset_not_found_returns_404(self, client):
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(None)
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
r = client.post(
"/api/media-assets/999/rights-declarations",
json={"target_visibility": "private", **_FULL_DECL},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 404
# ===========================================================================
# 6. Bulk-Patch P-06-Promotion im Batch
# ===========================================================================
class TestBulkPatchP06:
def test_bulk_promote_legacy_without_decl_reports_failure(self, client):
"""Bulk-Patch: Legacy-Asset ohne P-06-Felder landet in 'failed', nicht 422."""
app.dependency_overrides[get_tenant_context] = lambda: _SUPERADMIN_TENANT
mock_cm, mock_cur = _make_db_mocks(_PRIVATE_ASSET)
mock_cur.fetchone.side_effect = [
_PRIVATE_ASSET,
{"rights_status": "legacy_unreviewed", "rights_declared_for_visibility": None},
]
with ExitStack() as stack:
stack.enter_context(patch("routers.media_assets.get_db", return_value=mock_cm))
stack.enter_context(patch("routers.media_assets.get_cursor", return_value=mock_cur))
stack.enter_context(patch("routers.media_assets.assert_can_edit_media_asset_metadata"))
stack.enter_context(patch("routers.media_assets.assert_valid_governance_visibility"))
stack.enter_context(patch("routers.media_assets._media_assets_tags_column_present", return_value=False))
stack.enter_context(patch("routers.media_assets.get_effective_media_root", return_value="/tmp"))
stack.enter_context(patch("routers.media_assets._relocate_asset_file_if_governance_changed", return_value=None))
r = client.post(
"/api/media-assets/bulk-patch",
json={
"media_asset_ids": [42],
"visibility": "club",
"club_id": 7,
"copyright_notice": "Rechteinhaber 2026",
},
headers={"X-Auth-Token": "t"},
)
assert r.status_code == 200
data = r.json()
assert data["failed_count"] == 1
assert data["updated_count"] == 0
assert "LEGACY_REDECLARATION_REQUIRED" in data["failed"][0]["detail"] or \
"RIGHTS_SCOPE_INSUFFICIENT" in data["failed"][0]["detail"]

View File

@ -0,0 +1,281 @@
"""
P-11: Legal-Hold Backend-Tests.
Abgedeckt (15 Faelle):
1. assert_superadmin_for_legal_hold Superadmin darf
2. assert_superadmin_for_legal_hold Nicht-Superadmin wird geblockt
3. is_media_available_for_normal_use ohne Hold True
4. is_media_available_for_normal_use mit Hold False
5. assert_not_under_legal_hold ohne Hold passiert nichts
6. assert_not_under_legal_hold mit Hold wirft 403 LEGAL_HOLD_ACTIVE
7. set_legal_hold ungueltige reason_code 400
8. set_legal_hold leere reason_note 400
9. set_legal_hold Asset bereits unter Hold 409
10. set_legal_hold Erfolgspfad: DB-Update + Audit-Log
11. release_legal_hold leere release_note 400
12. release_legal_hold Asset nicht unter Hold 409
13. release_legal_hold Erfolgspfad ohne Deklaration legacy_unreviewed
14. release_legal_hold Erfolgspfad mit Deklaration declared
15. Retention-Job ueberspringt Legal-Hold-Assets (run_retention_pass query)
"""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch, call
import pytest
from fastapi import HTTPException
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from media_legal_hold import (
LEGAL_HOLD_REASON_CODES,
assert_not_under_legal_hold,
assert_superadmin_for_legal_hold,
is_media_available_for_normal_use,
release_legal_hold,
set_legal_hold,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _asset(legal_hold_active=False, rights_status="legacy_unreviewed", **kw) -> dict:
base = {
"id": 99,
"visibility": "private",
"lifecycle_state": "active",
"rights_status": rights_status,
"legal_hold_active": legal_hold_active,
"legal_hold_reason_code": None,
"legal_hold_reason_note": None,
}
base.update(kw)
return base
def _make_cur(fetchone_val, fetchone_seq=None):
"""Erstellt einen Mock-Cursor.
fetchone_seq: wenn angegeben, wird fetchone() sequenziell diese Werte liefern.
"""
cur = MagicMock()
if fetchone_seq is not None:
cur.fetchone.side_effect = fetchone_seq
else:
cur.fetchone.return_value = fetchone_val
return cur
def _dict_row(d: dict):
"""Simuliert ein psycopg2-DictRow-Objekt (keys() + Subscript)."""
m = MagicMock()
m.keys.return_value = list(d.keys())
m.__getitem__ = lambda self, k: d[k]
m.__iter__ = lambda self: iter(d.values())
return m
# ---------------------------------------------------------------------------
# 12: assert_superadmin_for_legal_hold
# ---------------------------------------------------------------------------
class TestAssertSuperadmin:
def test_superadmin_passes(self):
# kein Fehler
assert_superadmin_for_legal_hold("superadmin")
def test_non_superadmin_raises_403(self):
for role in ("admin", "user", "club_admin", None, ""):
with pytest.raises(HTTPException) as exc:
assert_superadmin_for_legal_hold(role)
assert exc.value.status_code == 403
# ---------------------------------------------------------------------------
# 34: is_media_available_for_normal_use
# ---------------------------------------------------------------------------
class TestIsMediaAvailable:
def test_no_hold_is_available(self):
assert is_media_available_for_normal_use(_asset(legal_hold_active=False)) is True
def test_hold_active_is_not_available(self):
assert is_media_available_for_normal_use(_asset(legal_hold_active=True)) is False
# ---------------------------------------------------------------------------
# 56: assert_not_under_legal_hold
# ---------------------------------------------------------------------------
class TestAssertNotUnderLegalHold:
def test_no_hold_does_not_raise(self):
assert_not_under_legal_hold(_asset(legal_hold_active=False))
def test_hold_active_raises_403_with_code(self):
with pytest.raises(HTTPException) as exc:
assert_not_under_legal_hold(_asset(legal_hold_active=True, id=55))
assert exc.value.status_code == 403
detail = exc.value.detail
assert isinstance(detail, dict)
assert detail["code"] == "LEGAL_HOLD_ACTIVE"
assert detail["asset_id"] == 55
# ---------------------------------------------------------------------------
# 710: set_legal_hold
# ---------------------------------------------------------------------------
class TestSetLegalHold:
def test_invalid_reason_code_raises_400(self):
cur = MagicMock()
conn = MagicMock()
with pytest.raises(HTTPException) as exc:
set_legal_hold(cur, conn, asset_id=1, acting_profile_id=1,
reason_code="totally_made_up", reason_note="some reason")
assert exc.value.status_code == 400
def test_empty_reason_note_raises_400(self):
cur = MagicMock()
conn = MagicMock()
with pytest.raises(HTTPException) as exc:
set_legal_hold(cur, conn, asset_id=1, acting_profile_id=1,
reason_code="rights_dispute", reason_note="")
assert exc.value.status_code == 400
def test_already_under_hold_raises_409(self):
asset = _asset(legal_hold_active=True)
cur = _make_cur(_dict_row(asset))
conn = MagicMock()
with patch("db.r2d", return_value=asset), \
patch("media_legal_hold.write_audit_log_entry"):
with pytest.raises(HTTPException) as exc:
set_legal_hold(cur, conn, asset_id=99, acting_profile_id=1,
reason_code="rights_dispute", reason_note="Verletzung bestätigt")
assert exc.value.status_code == 409
def test_success_sets_hold_and_writes_audit(self):
asset_before = _asset(legal_hold_active=False, rights_status="declared")
asset_after = {
**asset_before,
"legal_hold_active": True,
"legal_hold_reason_code": "copyright_complaint",
"legal_hold_reason_note": "Urheberrechtsverletzung gemeldet",
"legal_hold_set_by_profile_id": 1,
"legal_hold_set_at": "2026-05-11T12:00:00Z",
"rights_status": "blocked",
}
cur = _make_cur(None)
cur.fetchone.side_effect = [_dict_row(asset_before), _dict_row(asset_after)]
conn = MagicMock()
with patch("db.r2d", side_effect=[asset_before, asset_after]), \
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
result = set_legal_hold(
cur, conn,
asset_id=99,
acting_profile_id=1,
reason_code="copyright_complaint",
reason_note="Urheberrechtsverletzung gemeldet",
)
assert result["legal_hold_active"] is True
assert result["rights_status"] == "blocked"
conn.commit.assert_called_once()
mock_audit.assert_called_once()
audit_call_kwargs = mock_audit.call_args
assert audit_call_kwargs[1]["event_type"] == "legal_hold_set"
assert audit_call_kwargs[1]["new_values"]["legal_hold_active"] is True
assert audit_call_kwargs[1]["new_values"]["reason_code"] == "copyright_complaint"
# ---------------------------------------------------------------------------
# 1114: release_legal_hold
# ---------------------------------------------------------------------------
class TestReleaseLegalHold:
def test_empty_release_note_raises_400(self):
cur = MagicMock()
conn = MagicMock()
with pytest.raises(HTTPException) as exc:
release_legal_hold(cur, conn, asset_id=99, acting_profile_id=1, release_note="")
assert exc.value.status_code == 400
def test_not_under_hold_raises_409(self):
asset = _asset(legal_hold_active=False)
cur = _make_cur(_dict_row(asset))
conn = MagicMock()
with patch("db.r2d", return_value=asset):
with pytest.raises(HTTPException) as exc:
release_legal_hold(cur, conn, asset_id=99, acting_profile_id=1,
release_note="Falschmeldung")
assert exc.value.status_code == 409
def test_release_without_declaration_restores_legacy_unreviewed(self):
asset = _asset(legal_hold_active=True, rights_status="blocked")
asset_after = {**asset, "legal_hold_active": False, "rights_status": "legacy_unreviewed",
"legal_hold_released_by_profile_id": 1, "legal_hold_release_note": "Klaerung abgeschlossen"}
cur = _make_cur(None)
# fetchone-Sequenz: 1) Asset (fuer r2d), 2) decl count row, 3) updated asset row (fuer r2d)
decl_cnt_row = type("Row", (), {"__getitem__": staticmethod(lambda k: 0), "__iter__": staticmethod(lambda: iter([0]))})()
cur.fetchone.side_effect = [_dict_row(asset), decl_cnt_row, _dict_row(asset_after)]
conn = MagicMock()
with patch("db.r2d", side_effect=[asset, asset_after]), \
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
result = release_legal_hold(
cur, conn, asset_id=99, acting_profile_id=1,
release_note="Klaerung abgeschlossen",
)
assert result["legal_hold_active"] is False
assert result["rights_status"] == "legacy_unreviewed"
conn.commit.assert_called_once()
mock_audit.assert_called_once()
assert mock_audit.call_args[1]["event_type"] == "legal_hold_released"
def test_release_with_declaration_restores_declared(self):
asset = _asset(legal_hold_active=True, rights_status="blocked")
asset_after = {**asset, "legal_hold_active": False, "rights_status": "declared",
"legal_hold_released_by_profile_id": 1, "legal_hold_release_note": "Einigung erzielt"}
cur = _make_cur(None)
decl_cnt_row = type("Row", (), {"__getitem__": staticmethod(lambda k: 1), "__iter__": staticmethod(lambda: iter([1]))})()
cur.fetchone.side_effect = [_dict_row(asset), decl_cnt_row, _dict_row(asset_after)]
conn = MagicMock()
with patch("db.r2d", side_effect=[asset, asset_after]), \
patch("media_legal_hold.write_audit_log_entry") as mock_audit:
result = release_legal_hold(
cur, conn, asset_id=99, acting_profile_id=1,
release_note="Einigung erzielt",
)
assert result["legal_hold_active"] is False
assert result["rights_status"] == "declared"
mock_audit.assert_called_once()
assert mock_audit.call_args[1]["new_values"]["rights_status"] == "declared"
# ---------------------------------------------------------------------------
# 15: Retention-Job ueberspringt Legal-Hold-Assets
# ---------------------------------------------------------------------------
class TestRetentionJobSkipsLegalHold:
def test_retention_queries_exclude_legal_hold(self):
"""Prueft dass run_retention_pass keine Legal-Hold-Assets anfasst."""
import inspect
import media_lifecycle
source = inspect.getsource(media_lifecycle.run_retention_pass)
# Beide Queries (trash_soft→trash_hidden und trash_hidden→purge)
# muessen den Legal-Hold-Filter enthalten
assert "legal_hold_active" in source, (
"run_retention_pass muss legal_hold_active in den Retention-Queries filtern"
)
# Sicherstellen, dass der Filter korrekt lautet (FALSE oder IS NULL)
assert "legal_hold_active = FALSE" in source or "legal_hold_active IS NULL" in source, (
"Filter muss 'legal_hold_active = FALSE' oder 'IS NULL' enthalten"
)

View File

@ -0,0 +1,344 @@
"""
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"
tenant.profile_id = 99
# COUNT-Abfrage fuer Club-Admin-Rollen → 0 → 403
cnt_row = _row({"cnt": 0})
mock_cur = MagicMock()
mock_cur.fetchone.return_value = cnt_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):
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",
"target_type": "media_asset", "target_id": 42, "resolution_note": None,
}[k]
existing_row.keys = lambda: ["id", "status", "target_type", "target_id", "resolution_note"]
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"

View File

@ -124,3 +124,27 @@ def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> N
r2 = client.get("/api/version")
assert r2.status_code == 200
assert r2.headers.get("x-content-type-options") == "nosniff"
def test_public_media_static_not_mounted_by_default() -> None:
"""/media/-StaticFiles-Mount darf ohne ALLOW_PUBLIC_MEDIA_STATIC nicht aktiv sein."""
snippet = """
from main import app
mounted = [getattr(r, 'path', '') for r in app.routes]
assert not any(p == '/media' for p in mounted), (
"ALLOW_PUBLIC_MEDIA_STATIC aktiv /media oeffentlich erreichbar. Vor Deploy entfernen."
)
"""
proc = _run_fresh_import_int(snippet, {"ENVIRONMENT": "production"})
assert proc.returncode == 0, proc.stderr + proc.stdout
def test_allow_public_media_static_activates_media_mount() -> None:
"""Dokumentiert: ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert /media ohne Authentifizierung."""
snippet = """
from main import app
mounted = [getattr(r, 'path', '') for r in app.routes]
assert any(p == '/media' for p in mounted), "/media-Mount wurde nicht aktiviert"
"""
proc = _run_fresh_import_int(snippet, {"ALLOW_PUBLIC_MEDIA_STATIC": "1"})
assert proc.returncode == 0, proc.stderr + proc.stdout

View File

@ -1,11 +1,12 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.65"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508049"
APP_VERSION = "0.8.94"
BUILD_DATE = "2026-05-11"
DB_SCHEMA_VERSION = "20260511053"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"legal_documents": "1.2.0", # jsPDF-Download auf LegalPage (oeffentlich) + Admin; Abschnitts-Sortierung/-Einfuegen
"auth": "1.2.3", # P-05b: reset-password min_length=8 via Pydantic PasswordResetConfirm
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
@ -13,11 +14,14 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.12.1", # official: nur Superadmin Lifecycle/PATCH; UI Lesemodus; Superadmin Upload-Verein = aktiv
"media_rights": "1.3.1", # acting_profile_id in write_audit_log_entry auf Optional[int] (P-13 anonyme Meldungen)
"media_assets": "1.18.1", # P-13: open_report_count in Listendaten (fuer Admins)
"media_legal_hold": "1.0.0", # P-11: Sofortsperre-Services (set_legal_hold, release_legal_hold)
"media_lifecycle": "1.1.0", # P-11: Retention-Job ueberspringt Legal-Hold-Assets
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.19.1", # Verein: PUT default_club_media_copyright + Prompt beim Speichern (fehlende File-Asset-Copyrights)
"exercises": "2.23.0", # P-11: enrich_exercise_detail + download_file blocken Legal-Hold-Assets (451)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -26,9 +30,262 @@ MODULE_VERSIONS = {
"membership": "1.0.0",
"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
"content_reports": "1.5.1", # P-13: Fruehzeitige 403 fuer plain Admin in set_legal_hold_from_report (CI-Fix)
}
CHANGELOG = [
{
"version": "0.8.94",
"date": "2026-05-11",
"changes": [
"Fix P-13: set_legal_hold_from_report wirft 403 fuer plain Admin vor DB-Zugriff (CI-Testkonsistenz).",
"Fix P-13: Tests test_list_inbox_requires_platform_admin und test_patch_report_under_review repariert.",
],
},
{
"version": "0.8.93",
"date": "2026-05-11",
"changes": [
"Fix P-13: Club-Admins können Inhaltsmeldungen zu Vereinsmedien bearbeiten (PATCH, GET-Detail).",
"Fix P-13: Club-Admins können Legal Hold auf Vereinsmedien (nicht 'official') aus Meldung heraus setzen.",
"Fix P-13: Abgeschlossene Meldungen in der Inbox in kollabierbare Archiv-Sektion verschoben.",
"Fix P-13: isClubAdmin + isPlatformAdmin im OrgInboxContext exponiert.",
],
},
{
"version": "0.8.92",
"date": "2026-05-11",
"changes": [
"Fix P-13: Badge in Medienbibliothek aktualisiert sich sofort nach Einreichen einer Meldung.",
"Fix P-13: Inbox zeigt Fehlermeldung statt leerem Bereich wenn Backend-Fehler auftritt.",
"Fix P-13: Meldungen lassen sich nach Abschluss wieder öffnen (Wieder-öffnen-Button).",
"Fix P-13: Bearbeitungskommentare werden separat im Audit-Log protokolliert.",
"Fix P-13: Reviewer-Felder werden beim Wieder-öffnen einer Meldung zurückgesetzt.",
"Fix P-13: Workflow-Balken im Meldungs-Detail zeigt aktuellen Bearbeitungsstand.",
],
},
{
"version": "0.8.91",
"date": "2026-05-11",
"changes": [
"Fix P-13: Name- und E-Mail-Felder im Melde-Dialog fuer eingeloggte Nutzer nicht mehr bearbeitbar (readOnly).",
"Fix P-13: Journaleintraege fuer content_report_filed leserlich (Meldegrund, Prioritaet, Status auf Deutsch).",
"Fix P-13: Badge auf Medienkacheln zeigt Anzahl offener Meldungen (nur fuer Admins).",
"Fix P-13: Statuswechsel an Meldungen werden im Journal des Mediums protokolliert.",
],
},
{
"version": "0.8.90",
"date": "2026-05-11",
"changes": [
"Fix P-13: Club-Admins sehen jetzt Inhaltsmeldungen ihrer Vereinsmedien in der Inbox (Backend-Filter + Frontend canSeeContentReports).",
"Fix P-13: Journaleintrag (content_report_filed) in media_asset_audit_log beim Einreichen einer Meldung (Migration 053).",
"Fix P-13: E-Mail-Benachrichtigung an alle Plattform-Admins bei neuer Meldung (best-effort).",
"Fix P-13: Bestätigungs-E-Mail an Melder beim Einreichen (best-effort).",
"Fix P-13: Medienname (target_filename/target_exercise_name) korrekt in Inbox-Liste angezeigt.",
],
},
{
"version": "0.8.89",
"date": "2026-05-11",
"changes": [
"Feat P-13: MediaPreviewModal — geteilter Medienvorschau-Dialog für alle Kontexte (Bibliothek, Übungsbearbeitung, angehängte Medien); optionale Melden- und Bearbeiten-Buttons im Header.",
"Feat P-13: Melde-Button im ExerciseFormPage-Viewer (verlinktes Medium → Vorschau → Melden).",
"Refactor: Inline-Vorschau-Blöcke in MediaLibraryPage, ExerciseFormPage und ExerciseAttachmentMediaStrip durch MediaPreviewModal ersetzt.",
],
},
{
"version": "0.8.88",
"date": "2026-05-11",
"changes": [
"Feat P-13: Melde-Button in Medienbibliothek (Grid + Liste) — öffnet ReportContentModal; nur aktive Medien ohne Legal Hold.",
"Feat P-13: Melde-Link an jedem Medium in ExerciseAttachmentMediaStrip (Lesemodus Übung).",
"Feat P-13: ReportContentModal — wiederverwendbares Formular (Grund, Beschreibung, Name, E-Mail, Gutglaubenserklärung); Vorausfüllung für eingeloggte Nutzer.",
],
},
{
"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",
"date": "2026-05-11",
"changes": [
"Fix P-11: download_exercise_media_file gibt 451 zurück für Legal-Hold-Assets (Datei nicht mehr auslieferbar).",
"Fix P-11: enrich_exercise_detail liefert asset_legal_hold_active im Media-Array (Frontend-Komponenten koennen Hold erkennen).",
"Fix P-11: ExerciseMediaEmbed + ExerciseMediaThumbTile zeigen 'Medium nicht verfügbar / Gesperrt' statt Datei laden.",
"Fix P-11: ExerciseFormPage Vorschau-Modal zeigt Hinweis statt Datei bei Legal-Hold.",
"Fix P-11: Media-Bibliothek-Liste (list_media_assets) schliesst Legal-Hold fuer Plattform-Admins aus — nur Superadmin sieht sie.",
],
},
{
"version": "0.8.85",
"date": "2026-05-11",
"changes": [
"Fix P-11: Listquery fehlte legal_hold_active/reason_code/reason_note/set_at — Badge und 'Sperre aufheben' im Modal waren nie sichtbar.",
"Fix P-11: submitLegalHold rief loadMedia() statt loadItems() auf ('loadMedia is not a function').",
"Fix P-11: Journal-Renderpfad verwendete nw.legal_hold_reason_code/note statt nw.reason_code/note (Keys aus dem Audit-Log).",
"Fix P-11: Archiv-Picker (ExerciseFormPage, ExerciseInlineFileMediaModal) filtert Legal-Hold-Assets auch fuer Superadmins heraus.",
],
},
{
"version": "0.8.84",
"date": "2026-05-11",
"changes": [
"Feat P-11: Legal-Hold-Sofortsperre — Migration 051 (legal_hold_active + Metadaten in media_assets; audit_log um legal_hold_set/released erweitert).",
"Feat P-11: media_legal_hold.py — zentrale Services set_legal_hold, release_legal_hold, assert_not_under_legal_hold.",
"Feat P-11: Retention-Job ueberspringt Assets mit legal_hold_active=TRUE (keine automatische Loeschung unter Hold).",
"Feat P-11: Neue Admin-Endpoints POST /api/admin/media-assets/{id}/legal-hold, POST …/legal-hold/release, GET …/legal-hold (nur Superadmin).",
"Feat P-11: Sichtbarkeitsfilter erweitert — normale Nutzer sehen Legal-Hold-Assets nicht; Superadmin/Plattform-Admin sehen sie.",
"Feat P-11: exercises from-asset prueft Legal-Hold (LEGAL_HOLD_ACTIVE 403) vor Verknuepfung.",
"Feat P-11: Frontend MediaLibraryPage — Legal-Hold-Badge, Superadmin-Aktionen, Bestaetigungs-Dialog, Journal-Renderpfad fuer legal_hold_set/released.",
"Tests: 15 Backend-Unit-Tests test_p11_legal_hold.py.",
],
},
{
"version": "0.8.83",
"date": "2026-05-11",
"changes": [
"Fix: Journal + Korrektur-Endpoint: club_admin-Pruefung nutzte falsches Schema (role-Spalte nicht in club_members, sondern in club_member_roles via has_club_role); lieferte immer 500.",
],
},
{
"version": "0.8.82",
"date": "2026-05-11",
"changes": [
"Feat P-06: Volljournal fuer Medien (Migration 050) — media_asset_audit_log protokolliert Sichtbarkeits-, Copyright-, Metadaten- und Lifecycle-Aenderungen automatisch.",
"Feat P-06: Korrektur-Deklaration (action_type='correction') via POST /api/admin/media-rights/assets/{id}/correction — zugaenglich fuer Superadmin, Uploader, Vereinsadmin.",
"Feat P-06: Journal-Endpoint gibt jetzt events[] (Deklarationen + Auditlog chronologisch gemischt) und can_correct zurueck; Zugriff fuer Superadmin + Uploader + Vereinsadmin.",
],
},
{
"version": "0.8.81",
"date": "2026-05-11",
"changes": [
"Fix P-06: check_rights_coverage gibt bei rights_status='declared' immer 'ok' zurueck — Erklaerung gilt sichtbarkeitsunabhaengig (Inhalt aendert sich nicht durch Sichtbarkeits-Promotion). 'insufficient'-Pfad entfernt. Tests angepasst.",
],
},
{
"version": "0.8.80",
"date": "2026-05-11",
"changes": [
"Fix P-06: Bei Sichtbarkeits-Promotion (private→club, club→official) oeffnet das Frontend bei RIGHTS_SCOPE_INSUFFICIENT / LEGACY_REDECLARATION_REQUIRED automatisch den Einwilligungsdialog (promotion/redeclaration-Modus) statt eines alert()-Fehlers; PATCH wird nach Bestaetigung mit P-06-Feldern wiederholt.",
],
},
{
"version": "0.8.79",
"date": "2026-05-11",
"changes": [
"Fix: profiles.username -> profiles.name in Journal-Endpoint (500er behoben); RightsDeclarationDialog: doppelter Abbrechen-Button entfernt (Header-X statt Text); Playwright P-01: Platzhalter-Pruefung optional (echtes Impressum deployed); Playwright P-06c: Selector-Konflikt durch UI-Fix beseitigt.",
],
},
{
"version": "0.8.78",
"date": "2026-05-11",
"changes": [
"P-06 Superadmin Medienjournal: GET /api/admin/media-rights/assets/{id}/journal liefert vollstaendiges Deklarations-Log pro Medium; Frontend: Journal-Button im Bearbeitungs-Modal (nur Superadmin), scrollbare Timeline aller Einwilligungserklaerungen mit Kontext-Feldern.",
],
},
{
"version": "0.8.77",
"date": "2026-05-11",
"changes": [
"P-06 Erweiterung: Migration 049 — 4 optionale Freitext-Kontextfelder in media_asset_rights_declarations (person_consent_context, parental_consent_context, music_rights_context, third_party_rights_context); copyright_notice direkt im Upload-Dialog erfassbar; alle drei Dialoge (RightsDeclarationDialog, ExerciseInlineFileMediaModal, ExerciseInlineEmbedModal) und alle Backend-Endpoints aktualisiert.",
],
},
{
"version": "0.8.76",
"date": "2026-05-11",
"changes": [
"Fix P-06: RightsDeclarationDialog in ExerciseInlineFileMediaModal (Upload-Tab) und ExerciseInlineEmbedModal integriert; vor jedem Exercise-Media-Upload oeffnet sich der vollstaendige Einwilligungsdialog statt eines nicht-benutzbaren alert()",
],
},
{
"version": "0.8.75",
"date": "2026-05-11",
"changes": [
"P-06 (Upload-Einwilligungsdialog) technisch umgesetzt unter konservativen Erstannahmen (p06-v1-conservative): Migration 048 (media_asset_rights_declarations + rights_status/rights_declared_for_visibility/rights_declared_at in media_assets); zentrales Policy-Modul media_rights.py; Bulk-Upload und PATCH/Bulk-Patch mit P-06-Enforcement; Re-Deklarations-Endpoint (POST /api/media-assets/{id}/rights-declarations); Admin-Legacy-Summary (GET /api/admin/media-rights/legacy-summary+/legacy-assets); exercises.py: P-06 bei upload_exercise_media und from-asset; Frontend: RightsDeclarationDialog + Altbestand-Indikator in Medienbibliothek. KRIT-04 bleibt offen bis juristische Validierung.",
],
},
{
"version": "0.8.74",
"date": "2026-05-10",
"changes": [
"Rechtstexte: echtes PDF-Download via jsPDF (pdf.save) statt Browser-Print-Dialog; LegalPage und AdminLegalDocumentsPage",
"Rechtstexte Admin: Abschnitts-Reihenfolge per Pfeil-Buttons aendern; neuen Abschnitt an beliebiger Stelle einfuegen",
],
},
{
"version": "0.8.73",
"date": "2026-05-10",
"changes": [
"Rechtstexte: PDF-Export via Browser-Print (kein neues Paket); Drucker-Button in AdminLegalDocumentsPage (laedt Volldokument) und auf LegalPage (nur bei veroeffentlichtem Inhalt); Dokument enthaelt Versionsnummer und Gueltigkeitsdatum",
],
},
{
"version": "0.8.72",
"date": "2026-05-10",
"changes": [
"Rechtstexte: Als-Entwurf-kopieren — POST /api/admin/legal-documents/{id}/copy-as-draft; Inhalt und Titel werden uebernommen, Versionsnummer inkrementiert",
],
},
{
"version": "0.8.71",
"date": "2026-05-10",
"changes": [
"Compliance P-01c: Admin-konfigurierbare Rechtstexte — DB 047 (legal_documents + legal_document_audit); Superadmin CRUD + Publish/Archive-Workflow; LegalPage laedt aus API mit Platzhalter-Fallback; AdminLegalDocumentsPage unter /admin/legal-documents",
],
},
{
"version": "0.8.70",
"date": "2026-05-10",
"changes": [
"Compliance P-01b: Einstellungen/Rechtliches (/settings/legal) fuer mobile/PWA-Darstellung; Hub mit Links zu Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie",
],
},
{
"version": "0.8.69",
"date": "2026-05-10",
"changes": [
"Compliance P-01 (KRIT-01) technischer Teil: Rechtstextseiten /impressum, /datenschutz, /nutzungsbedingungen, /medienrichtlinie als oeffentliche Routen angelegt (Platzhalter, kein Auth erforderlich)",
"Login-Seite und Desktop-Sidebar enthalten Links zu allen vier Rechtstextseiten",
],
},
{
"version": "0.8.68",
"date": "2026-05-10",
"changes": [
"Sicherheit P-12 (MITT-05): logout() bereinigt alle sj_coach_*-Schlüssel aus sessionStorage; Präfix-gezielte Löschung, keine fremden Schlüssel betroffen",
],
},
{
"version": "0.8.67",
"date": "2026-05-10",
"changes": [
"Sicherheit P-05b: PasswordResetConfirm.new_password min_length=8 (POST /auth/reset-password)",
"Sicherheit P-03b: Retention-Default HIDDEN_TO_PURGE 90->30 Tage (gemaess Loeschkonzept 30+30)",
"Sicherheit P-04: Positive Promotion-Tests haerten (explizit 200 + Payload); Umlaut in Fehlermeldung",
],
},
{
"version": "0.8.66",
"date": "2026-05-09",
"changes": [
"Sicherheit P-03: Papierkorb-Retention-Job (media_retention_job.py) als Docker-Service retention-cron aktiviert (lauft taglich um 03:00 Uhr)",
"Sicherheit P-04: Copyright-Pflicht bei Sichtbarkeits-Promotion auf club/official in PATCH und bulk-patch (media_assets)",
"Sicherheit P-05: Passwort-Mindestlange PUT /auth/pin von 4 auf 8 Zeichen angehoben",
"Sicherheit P-07: Release-Test fur ALLOW_PUBLIC_MEDIA_STATIC in test_security_release.py ergaenzt",
"Sicherheit P-23: LoginPage minLength 6 auf 8 angehoben; hartcodierter Versionsstring entfernt",
"Sicherheit P-24: CORS allow_methods und allow_headers auf benotigte Werte eingeschrankt",
],
},
{
"version": "0.8.65",
"date": "2026-05-08",

View File

@ -81,6 +81,44 @@ services:
networks:
- shinkan-network
retention-cron:
build:
context: ./backend
dockerfile: Dockerfile
container_name: shinkan-retention-cron
command: >
python -c "
import time, subprocess, sys, os, datetime
def next_3am():
now = datetime.datetime.now()
target = now.replace(hour=3, minute=0, second=0, microsecond=0)
if target <= now:
target += datetime.timedelta(days=1)
return (target - now).total_seconds()
subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False)
while True:
time.sleep(next_3am())
subprocess.run([sys.executable, 'scripts/media_retention_job.py'], check=False)
"
working_dir: /app
environment:
DB_HOST: postgres
DB_PORT: 5432
DB_NAME: shinkan
DB_USER: shinkan_user
DB_PASSWORD: ${DB_PASSWORD}
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
# Loeschkonzept (Audit P-03b): 30 Tage Soft-Trash, dann 30 Tage Hidden, dann Purge (gesamt 60 Tage).
MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS: "${MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS:-30}"
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS:-30}"
volumes:
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
depends_on:
- postgres
restart: unless-stopped
networks:
- shinkan-network
volumes:
shinkan-db-data:

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-07
**App-Version / DB-Schema:** App **0.8.59**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
**Stand:** 2026-05-11
**App-Version / DB-Schema:** App **0.8.94**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
@ -119,13 +119,43 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
---
## 5b. P-13: Content-Meldeverfahren (vollständig implementiert, 2026-05-11)
**DSA-konformes Meldeverfahren (KRIT-03) — App 0.8.870.8.94.**
**Backend (`backend/routers/content_reports.py`, Migrationen 052053):**
- `POST /api/content-reports` — optionale Auth; `official`-Medien ohne Login meldbar; E-Mail-Bestätigung an Melder + Benachrichtigung aller Plattform-Admins (best-effort).
- `GET /api/me/inbox/content-reports` — Plattform-Admin: alle Meldungen; Club-Admin: nur Meldungen zu Medien eigener Vereine.
- `GET /api/content-reports/{id}` — Plattform-Admin + zuständiger Club-Admin.
- `PATCH /api/content-reports/{id}` — Status-Übergang (submitted → under_review → resolved/rejected); Wiedereröffnen (→ submitted) setzt Prüferfelder zurück; Audit-Log-Einträge bei Status- und Notizänderungen.
- `POST /api/content-reports/{id}/legal-hold` — Superadmin (immer) oder Club-Admin (Vereinsmedien, nicht `official`); integriert P-11 `set_legal_hold()`; plain Admin erhält früh 403.
- Automatische Priorisierung `high` für `minors`/`illegal_content`/`youth_protection`.
- Migration 053: `content_report_filed` Event-Typ in `media_asset_audit_log` CHECK-Constraint.
- `open_report_count` in `list_media_assets`-Response für Admin-Nutzer.
- 15 Backend-Tests in `backend/tests/test_p13_content_reports.py` (alle grün nach CI-Fix 0.8.94).
**Frontend:**
- `ReportContentModal.jsx` — Melde-Formular; `onSuccess`-Callback; readOnly-Felder für eingeloggte Nutzer.
- `MediaPreviewModal.jsx` — geteilter Vorschau-Dialog; optionale Melden- und Bearbeiten-Buttons.
- `InboxPage.jsx` — zweiter Abschnitt „Inhaltsmeldungen”; `WorkflowBar` (3-Schritte-Fortschrittsbalken); `ReportDetailModal` mit Workflow, Prüferinfo, Notiz, Wiedereröffnen-Button; Archiv-Trennung (offen vs. abgeschlossen, kollabierbar); Legal-Hold-Button für Superadmin + Club-Admin.
- `OrgInboxContext.jsx` — liefert `contentReports`, `contentReportCount`, `canAccessContentReports`, `isClubAdmin`, `isPlatformAdmin`, `isSuperadmin`, `contentReportsError`; Club-Admins haben Zugriff auf Berichte.
- `MediaLibraryPage.jsx` — rotes Badge mit `open_report_count` auf Medienkarten; Journal-Eintrag für `content_report_filed`; Badge-Aktualisierung via `onSuccess`.
- Melde-Button in **MediaLibraryPage** (Grid + Liste + Viewer), **ExerciseFormPage** (Viewer), **ExerciseAttachmentMediaStrip** (Viewer).
**Offen (explizit zurückgestellt):**
- P-14 Moderations-UI (eigene Seite), P-15 Uploader-Benachrichtigung bei Sperrung, P-16 Beschwerdeverfahren.
- Melde-Einstieg im Coaching-Modus (Feedback-Schritt, nicht kritisch).
---
## 6. Nächste Session — sinnvolle Arbeitspakete
1. **Inline §11:** Syntax festlegen (z.B. `{{exerciseMedia:id}}` → kanonisches HTML), Server normalisieren bei Speichern, einen **`renderExerciseRichText()`**-Pfad im Frontend.
2. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
3. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
4. **S3/Adapter:** Speicher-Abstraktion §7 — wenn Produkt es verlangt.
5. **Rahmen/UI:** Kalender „aus Rahmen übernehmen“ weiter anbinden (parallel, unabhängig von Medien).
1. **P-13 Frontend-Verifikation:** Melde-Flow in Medienbibliothek, Inbox-Workflow (Status, Archiv, Wiedereröffnen), Club-Admin-Ansicht manuell auf Dev-System durchspielen. E-Mail-Benachrichtigungen verifizieren (SMTP-Log).
2. **Inline §11:** Syntax festlegen (`{{exerciseMedia:id}}` → kanonisches HTML), Server normalisieren bei Speichern, einen `renderExerciseRichText()`-Pfad im Frontend.
3. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
4. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
5. **S3/Adapter:** Speicher-Abstraktion §7 — wenn Produkt es verlangt.
6. **Rahmen/UI:** Kalender „aus Rahmen übernehmen” weiter anbinden (parallel, unabhängig von Medien).
---

992
docs/compliance-audit.md Normal file
View File

@ -0,0 +1,992 @@
# Compliance-Audit Shinkan Jinkendo
> **Status:** Entwurf — technischer Audit, kein Rechtsanwalt
> **Datum:** 2026-05-09
> **Auditor:** Claude Code
> **App-Version:** 0.8.65
> **Rechtlicher Hinweis:** Dieses Dokument ist eine technische Analyse. Es ersetzt keine Rechtsberatung. Alle als „juristisch zu prüfen" markierten Punkte müssen durch einen Rechtsanwalt oder Datenschutzbeauftragten bewertet werden. Kein Code wurde verändert.
---
## 1. Executive Summary
Die Shinkan Jinkendo App ist technisch solide aufgebaut: robuste Mandantentrennung (TenantContext), mehrstufiges Löschkonzept für Medien, serverseitig erzwungene Zugriffskontrolle. Die Kernarchitektur der Datenschicht ist gut.
**Kritische Compliance-Lücken:**
- Keine Rechtstexte (Impressum, Datenschutzerklärung, Nutzungsbedingungen, Medienrichtlinie)
- Kein DSA-konformes Meldeverfahren für rechtswidrige Inhalte (UGC-Plattform)
- Kein Recht-am-eigenen-Bild/Minderjährigen-Check beim Medienupload
- Kein Self-Service-Löschrecht für Nutzer (nur Admin kann Konten löschen)
- Auth-Token im localStorage (XSS-Risiko, TDDDG-Dokumentationspflicht)
- HSTS-Header fehlt in der Nginx-Konfiguration
- Papierkorb-Retention-Job nicht automatisch geplant
- Passwort-Mindestlänge inkonsistent (Register: 8, PIN-Änderung: 4 Zeichen)
Vor öffentlichem Betrieb sind die kritischen Findings (KRIT-01 bis KRIT-07) zu adressieren.
---
## 2. Scope
| Bereich | Geprüft |
|---------|---------|
| Backend-Router (alle .py) | ✓ |
| Datenbankmigrationen (001046) | ✓ |
| Frontend App.jsx, Routing, Auth | ✓ |
| API-Authentifizierung und Autorisierung | ✓ |
| Mandanten-/Zugriffschicht (TenantContext, club_tenancy) | ✓ |
| Medien-Archiv (media_assets, lifecycle) | ✓ |
| PWA-Konfiguration (manifest.webmanifest) | ✓ |
| Nginx-Konfiguration (nginx.conf) | ✓ |
| Docker-Compose (docker-compose.yml) | ✓ |
| Vorhandene Tests (backend/tests/*.py) | ✓ |
| LocalStorage / SessionStorage Nutzung | ✓ |
| Rechtstexte (Impressum, DSGVO, AGB) | ✓ |
| CSP / Security-Header | ✓ |
| Passwort-Handling, Session-Management | ✓ |
---
## 3. Annahmen
- App ist öffentlich im Internet erreichbar unter `shinkan.jinkendo.de` (HTTPS)
- SSL/TLS-Terminierung erfolgt am externen Reverse-Proxy vor dem Nginx-Container
- Betreiber ist im EU-Raum ansässig (DSGVO anwendbar)
- Minderjährige können sich registrieren (keine Altersverifikation vorhanden)
- Die Plattform erlaubt Upload und Anzeige von Bildern und Videos mit Personenabbildungen
---
## 4. Nicht geprüfte Bereiche
- Produktions-Infrastruktur (Synology NAS, Raspberry Pi 5) nur Konfigurationsdateien
- Netzwerkinfrastruktur (Fritz!Box) außerhalb des Repos
- SMTP-Anbieter im Detail (Anbieter unbekannt aus Umgebungsvariablen)
- Aktive Penetrationstests
- Backup-Prozess und Restore-Test (kein Skript im Repository)
---
## 5. Technische Bestandsaufnahme
### 5.1 Architektur
| Komponente | Technologie | Sicherheitsrelevanz |
|-----------|-------------|---------------------|
| Frontend | React 18 + Vite, SPA | Routing, Token-Speicherung |
| Backend | FastAPI Python 3.12 | Zugriffskontrolle, Validierung |
| Datenbank | PostgreSQL 16 Alpine | Datenhaltung, Mandantentrennung |
| Proxy | Nginx (Docker) | CSP, Security-Header, Upload-Limit |
| Storage | Lokaler Bind-Mount via Docker | Medienspeicherung |
| Auth | Token-basiert (Sessions-Tabelle) | Session-Management |
| PWA | Web App Manifest + Icons | Offline-Caching (kein Service Worker!) |
| E-Mail | SMTP (konfigurierbar) | Registrierung, Passwort-Reset |
| KI | OpenRouter (optional, nicht MVP) | KI-Features |
### 5.2 Authentifizierung
- Token: `secrets.token_urlsafe(32)` (kryptografisch sicher)
- Hashing: bcrypt mit auto-Upgrade von Legacy SHA256
- Session-Ablauf: 30 Tage (konfigurierbar per `session_days`)
- Rate-Limiting: Login 30/min, Forgot-Password 3/min, Register 3/hour (slowapi)
- No-Enumeration: `/forgot-password` gibt keine Info über E-Mail-Existenz preis
### 5.3 Rollen (global)
| Rolle | Rechte |
|-------|--------|
| `trainer` | Standard-Nutzer; Upload, private Übungen, Planung |
| `admin` | Plattform-Admin; alle Vereine, alle Profile einsehbar |
| `superadmin` | Vollzugriff; Official-Promotion, physische Löschung, Admin-Konfiguration |
### 5.4 Vereinsrollen (pro Verein)
| Rolle | Rechte |
|-------|--------|
| `club_admin` | Vereinsstruktur, Mitglieder, Vereins-Medien/Übungen |
| `trainer` | Training planen, Übungen verwalten |
| `content_editor` | Inhalte bearbeiten |
| `division_lead` | Spartenleitung |
### 5.5 PWA / Service Worker
- **Kein Service Worker** im Repository vorhanden
- Keine Workbox- oder sw.js-Datei gefunden
- **Bedeutung:** Das Hauptrisiko (private Medien im PWA-Cache) entfällt mangels Service Worker
### 5.6 Browser-Storage-Nutzung
| Speicherart | Inhalt | TDDDG-Klassifikation |
|-------------|--------|----------------------|
| `localStorage['authToken']` | Auth-Session-Token | Technisch notwendig |
| `localStorage['shinkan_active_club']` | Aktiver Verein (ID) | Technisch notwendig |
| `localStorage['shinkan_active_profile']` | Profil-ID | Technisch notwendig |
| `sessionStorage[storageStepKey]` | Trainingsschritt (Coach-Page) | Session-temporär, nicht personenbezogen |
| `sessionStorage[storageDeltasKey]` | Trainingsdeltas JSON | Session-temporär |
| `sessionStorage[storageDebriefKey]` | Debrief-Status | Session-temporär |
| Cookies | **keine** | |
| IndexedDB | **keine** | |
---
## 6. Datenflussanalyse
### 6.1 Registrierung / Login
```
Nutzer → POST /api/auth/register → Profil (inaktiv) + Verifikations-E-Mail
Nutzer → E-Mail-Link → GET /api/auth/verify/{token}
→ Profil aktiv, Session-Token in Response
→ Frontend: localStorage.setItem('authToken', token)
Nutzer → POST /api/auth/login → Token in Response
→ Frontend: localStorage.setItem('authToken', token)
```
Gespeicherte Daten: Name, E-Mail, bcrypt-Hash, Rolle, Tier, trial_ends_at, email_verified, verification_token (temporär, wird nach Verifikation gelöscht)
### 6.2 Medienupload
```
Nutzer → POST /api/exercises/{id}/media (multipart) [50 MB Limit]
→ MIME-Type-Prüfung (magic bytes)
→ SHA256-Hash (Deduplizierung)
→ Dateispeicherung: library/{scope}/{kind}/{sha256}{ext}
→ DB-Eintrag: media_assets + exercise_media
Admin → POST /api/media-assets/bulk-upload [1 GB Limit]
→ gleicher Pfad; Sichtbarkeit + Verein als Formular-Parameter
```
### 6.3 Medienpromotion
```
Vereins-Admin → PATCH /api/media-assets/{id}
→ assert_valid_governance_visibility() → Mitgliedschaftsprüfung
→ Bei visibility=club: club_id Pflicht + Mitgliedschaft
→ Bei visibility=official: NUR Superadmin
→ copyright_notice: KEIN Pflichtfeld (nur im exercises-Router für official)
PROBLEM: Copyright-Pflicht ist NICHT im media_assets-Router für alle Promotions implementiert
```
### 6.4 Medienlöschung
```
Stufe 1 (Soft-Trash, lifecycle_state='trash_soft'):
→ Manuell durch Eigentümer / Vereins-Admin / Superadmin
→ Datei bleibt auf Disk; weiterhin sichtbar (je nach Exercise-Implementierung)
Stufe 2 (Hidden, lifecycle_state='trash_hidden'):
→ Nach 30 Tagen (Job) oder manuell
→ Nicht mehr in normalen Abfragen sichtbar
Stufe 3 (Purge):
→ Nach weiteren 30 Tagen (Job) oder Superadmin manuell
→ Datei physisch gelöscht
PROBLEM: media_retention_job.py ist NICHT automatisch geplant
```
### 6.5 Rechteprüfung
```
Jeder Request → require_auth() → Token aus X-Auth-Token-Header → Session aus DB
Vereinsdaten → get_tenant_context() → TenantContext (profile_id, role, effective_club_id)
Listenabfragen → library_content_visibility_sql() → SQL WHERE-Baustein
Schreibzugriffe → assert_valid_governance_visibility() → 403 bei Verstoß
```
---
## 7. Rollen- und Rechteanalyse
### 7.1 Mandantentrennung Stärken
- `TenantContext` konsequent in allen vereinsrelevanten Routern via `Depends(get_tenant_context)`
- `library_content_visibility_sql()` als zentraler Sichtbarkeits-Filter (SQL-Ebene)
- `effective_club_id` aus Header nur für Mitglieder, beliebig nur für Plattform-Admins
- Integrationstests vorhanden: `test_access_layer_integration.py`
### 7.2 Klarstellung: Wer kann Vereinsmedien bearbeiten?
Die Audit-Anforderung „alle Vereinsnutzer können bearbeiten" trifft auf die tatsächliche Implementierung **nicht** zu. In `_item_permissions()` (media_assets.py) ist `edit_metadata` nur für `club_admin`-Rolle oder Plattform-Admin True normale Mitglieder können Vereinsmedien nicht bearbeiten. **Dies ist ein positiver Befund.**
### 7.3 Profil-Löschung (DSGVO-Lücke)
`DELETE /api/profiles/{pid}` nur Plattform-Admin. Nutzer können ihr eigenes Konto **nicht** selbst löschen. Potenzielle DSGVO-Verletzung (Art. 17).
---
## 8. Medienrechteanalyse
### 8.1 Copyright-Feld
- Vorhanden: `copyright_notice` (max. 8000 Zeichen) in `media_assets`
- Pflichtfeld bei `exercise_media` mit `visibility='official'` (exercises-Router)
- **NICHT** Pflichtfeld beim direkten Upload in das Medienarchiv
- **NICHT** Pflichtfeld bei Promotion von `private` zu `club`
- **NICHT** dokumentiert: Wer hat erklärt? Wann? Welche Lizenzversion?
### 8.2 Rechteerklärung beim Upload
- Keine Einwilligungserklärung beim Upload: „Ich bestätige, alle Rechte an dieser Datei zu besitzen"
- Kein Upload-Dialog mit Pflicht-Checkbox
- Kein Hinweis auf verbotene Inhalte (Rechte Dritter, Persönlichkeitsrechte)
### 8.3 Recht am eigenen Bild
- Keine Abfrage, ob erkennbare Personen abgebildet sind
- Keine Abfrage, ob Minderjährige enthalten sind
- Keine Abfrage nach Einwilligung der abgebildeten Personen
- Juristisch zu prüfen: Anforderungen nach §22 KUG
---
## 9. Löschkonzeptanalyse
### 9.1 Stärken
- Klares 3-Stufen-Lifecycle-Modell (active → trash_soft → trash_hidden → purged)
- Superadmin-Direktlöschung als Sofortmaßnahme
- SHA256-Deduplizierung verhindert doppelte physische Dateien
- Datei-Relokation bei Sichtbarkeitsänderung implementiert
### 9.2 Lücken
| Problem | Risiko |
|---------|--------|
| Papierkorb-Job nicht automatisch geplant | Dateien bleiben physisch nach Ablauf der Fristen |
| Keine Löschung aus Backups dokumentiert | DSGVO Art. 17: Backup-Retention oder Löschprozess nötig |
| Kein Legal-Hold-Status | Bei Rechtsverletzung dauert es 30 Tage bis zur vollständigen Unsichtbarkeit |
| Kein Audit-Log für Löschgründe | Keine Nachvollziehbarkeit für DSA/DSGVO |
| Kein Uploader-Benachrichtigungssystem | Bei Sperrung / Löschung kein Feedback an Uploader |
---
## 10. PWA- / Storage-Analyse
### 10.1 Positiv
- Kein Service Worker → kein PWA-Cache-Risiko für Medien
- Keine Cookies → kein Cookie-Banner nötig (für Cookies)
- CSP-Header gesetzt: `script-src 'self'` (XSS-Mitigation)
### 10.2 LocalStorage-Bewertung
Die localStorage-Nutzung ist technisch notwendig (Auth, Mandantenkontext). Nach TDDDG §25 ist technisch notwendige Speicherung ohne Einwilligung zulässig. Dokumentation in der Datenschutzerklärung ist Pflicht.
### 10.3 Token-Sicherheit
- Auth-Token in `localStorage`: vulnerabel bei XSS
- CSP `script-src 'self'` reduziert XSS-Risiko erheblich
- Kein CSRF-Problem (Token im Header, nicht in Cookie)
- `HttpOnly`-Cookie wäre sicherer, erfordert Architekturanpassung
---
## 11. Datenschutzanalyse (DSGVO)
### 11.1 Identifizierte Verarbeitungsvorgänge
| Vorgang | Rechtsgrundlage (technisch) | VVT-Status |
|---------|----------------------------|-----------|
| Registrierung (Name, E-Mail, Passwort-Hash) | Vertrag (Art. 6 Abs. 1 lit. b) | ❌ Kein VVT |
| Login / Session-Management | Berechtigtes Interesse | ❌ Kein VVT |
| E-Mail-Versand | Vertragserfüllung | ❌ Kein VVT, SMTP-Anbieter unbekannt |
| Medienupload (Bilder/Videos) | Einwilligung oder Vertragserfüllung | ❌ Keine Einwilligung abgeholt |
| Vereinszugehörigkeit | Vertragserfüllung | ❌ Kein VVT |
| Training-Logging | Berechtigtes Interesse | ❌ Kein VVT |
| Backup (implizit) | Berechtigtes Interesse | ❌ Keine Retention dokumentiert |
### 11.2 Betroffenenrechte
| Recht | Status |
|-------|--------|
| Auskunft (Art. 15) | ❌ Kein Self-Service-Export |
| Berichtigung (Art. 16) | ⚠ Nur eigener Name/E-Mail über Einstellungen |
| Löschung (Art. 17) | ❌ Kein Self-Service-Löschung |
| Einschränkung (Art. 18) | ❌ Nicht implementiert |
| Datenübertragbarkeit (Art. 20) | ❌ Kein Export-Endpoint |
| Widerspruch (Art. 21) | ❌ Kein Mechanismus |
### 11.3 Auftragsverarbeiter (identifiziert)
| Dienst | Anbieter | AV-Vertrag |
|--------|----------|-----------|
| Hosting | Selbstbetrieb (Raspberry Pi) | Entfällt |
| SMTP / E-Mail | Unbekannt (Env-Variable) | ❌ Nicht dokumentiert |
| MediaWiki-Import | karatetrainer.net | ❌ Nicht dokumentiert |
| OpenRouter (KI) | OpenRouter.ai | ❌ Nicht dokumentiert |
### 11.4 Minderjährige
- Keine Altersverifikation bei Registrierung
- Keine besondere Schutzmaßnahme
- Juristisch zu prüfen: §8 DSGVO
---
## 12. DSA-/UGC-Analyse
### 12.1 Einordnung
Die App erlaubt Upload von User Generated Content (Bilder, Videos). Inhalte können öffentlich sichtbar sein (`official`-Stufe: plattformweit). Dies ist UGC im Sinne des DSA.
**Juristisch zu prüfen:** Ab welcher Nutzerzahl und unter welchen Voraussetzungen der DSA für diese Plattform gilt.
### 12.2 Fehlende Mechanismen
| DSA-Anforderung | Status |
|-----------------|--------|
| Meldeverfahren für rechtswidrige Inhalte | ❌ |
| „Inhalt melden"-Funktion | ❌ |
| Moderations-Backend mit Statuswerten | ❌ |
| Benachrichtigung des Uploaders bei Sperrung | ❌ |
| Begründung für Moderationsentscheidungen | ❌ |
| Beschwerdemechanismus | ❌ |
| Eskalation für schwere Inhalte (CSAM, Straftaten) | ❌ |
| Audit-Log für Meldungen und Entscheidungen | ❌ |
### 12.3 Was vorhanden ist (Notfall-Maßnahmen)
- Superadmin kann Inhalte sofort physisch löschen (`superadmin_hard_delete`)
- Lifecycle-System ermöglicht schrittweise Deaktivierung
- `official`-Promotion nur durch Superadmin (redaktioneller Prozess)
---
## 13. Sicherheitsanalyse
### 13.1 Positiv bewertete Maßnahmen
| Maßnahme | Status |
|----------|--------|
| HTTPS (Produktion via Reverse-Proxy) | ✓ |
| bcrypt Passwort-Hashing mit Legacy-SHA256-Upgrade | ✓ |
| Rate-Limiting (slowapi) | ✓ |
| CSRF-Schutz (Token im Header, nicht Cookie) | ✓ |
| SQL-Injection-Schutz (parameterisierte Queries) | ✓ |
| CSP-Header (nginx) | ✓ |
| X-Content-Type-Options: nosniff (nginx + FastAPI-Middleware) | ✓ |
| X-Frame-Options: SAMEORIGIN | ✓ |
| Referrer-Policy: strict-origin-when-cross-origin | ✓ |
| Permissions-Policy (camera/mic/geo) | ✓ |
| OpenAPI in Produktion deaktiviert | ✓ |
| DB-Port nur localhost exponiert | ✓ |
| MIME-Type-Validierung beim Upload | ✓ |
| SHA256-Integritätsprüfung + Deduplizierung | ✓ |
| Secrets in .env (nicht im Code) | ✓ |
| User-Enumeration verhindert (forgot-password, resend-verification) | ✓ |
| Path-Traversal-Schutz in media_storage.py (`path_under_media_root` + `.relative_to()`) | ✓ |
| Club-Name-Slugify: nur `[a-z0-9-]` im Dateipfad | ✓ |
| CORS: Origins eingeschränkt (ALLOWED_ORIGINS aus Env) | ✓ |
### 13.2 Sicherheitslücken
| ID | Titel | Schwere | Datei/Nachweis |
|----|-------|---------|----------------|
| SEC-01 | Kein HSTS-Header | Hoch | `frontend/nginx.conf` kein `Strict-Transport-Security` |
| SEC-02 | Auth-Token in localStorage | Mittel | `frontend/src/context/AuthContext.jsx:47` |
| SEC-03 | `style-src 'unsafe-inline'` in CSP | Niedrig | `frontend/nginx.conf:23` |
| SEC-04 | Passwort-Mindestlänge inkonsistent: Backend 3 Stellen, Frontend-Feld minLength=6, Backend-Register-Minimum=8 | Mittel | `backend/routers/auth.py:104` (`< 4`), `frontend/src/pages/LoginPage.jsx:175` (`minLength="6"`) |
| SEC-05 | ALLOW_PUBLIC_MEDIA_STATIC umgeht Auth für alle Medien | Hoch | `backend/main.py:222-223` |
| SEC-06 | Kein MFA für Superadmins | Mittel | Kein TOTP/OTP implementiert |
| SEC-07 | Kein Audit-Log für Admin-Aktionen | Mittel | Keine `admin_audit_log`-Tabelle |
| SEC-08 | Password-Reset-Token in sessions-Tabelle (Präfix `reset_`) | Niedrig | `backend/routers/auth.py:143` |
| SEC-09 | Kein Backup-Konzept dokumentiert | Mittel | Kein Backup-Skript im Repo |
| SEC-10 | Kein Anti-Virus-Scan für Uploads | Niedrig | Kein ClamAV o.ä. |
| SEC-11 | Kein genereller HTML-Sanitizer für Rich-Text-Felder | Mittel | `backend/exercise_rich_text.py` nur Inline-Media-Normalisierung, kein bleach/nh3 |
| SEC-12 | `minLength="6"` im Login-Formular, Backend fordert 8 Zeichen | Niedrig | `frontend/src/pages/LoginPage.jsx:175` |
| SEC-13 | Hartcodierte Versionsangabe `v0.1.0 • Development` auf Login-Seite (falsch + Info-Leak) | Niedrig | `frontend/src/pages/LoginPage.jsx:242` |
| SEC-14 | CORS: `allow_methods=["*"]` und `allow_headers=["*"]` breiter als nötig | Niedrig | `backend/main.py:84-87` |
### 13.3 Ergänzende Befunde aus Restprüfung
#### main.py — CORS-Konfiguration
```python
app.add_middleware(
CORSMiddleware,
allow_origins=ALLOWED_ORIGINS, # ✓ aus Env, keine Wildcard-Origins
allow_credentials=True, # ✓ korrekt (kein * + credentials)
allow_methods=["*"], # ⚠ breiter als nötig
allow_headers=["*"], # ⚠ breiter als nötig
)
```
`allow_credentials=True` in Kombination mit `allow_origins=["*"]` wäre ein kritischer Fehler (FastAPI würde ihn aber abweisen). Durch die explizite Origin-Liste ist das Risiko gering. `allow_methods=["*"]` und `allow_headers=["*"]` könnten auf die tatsächlich benötigten Methoden (GET, POST, PUT, PATCH, DELETE) und Header (X-Auth-Token, X-Active-Club-Id, Content-Type) eingeschränkt werden.
#### media_storage.py — Path-Traversal-Schutz (positiv)
`path_under_media_root()` kombiniert zwei unabhängige Prüfungen:
1. String-Prüfung: `".." in key.split("/")`
2. Filesystem-Prüfung: `p.relative_to(media_root.resolve())`
Die Dateiendung wird auf 16 Zeichen begrenzt. Club-Namen werden auf `[a-z0-9-]` normalisiert. SHA256 als Dateiname ist manipulationssicher. **Bewertung: Gut implementiert, kein Path-Traversal-Risiko erkennbar.**
#### exercise_rich_text.py — Fehlender genereller HTML-Sanitizer
Das Modul normalisiert ausschließlich das Inline-Media-Markup (`{{exerciseMedia:id}}` → `<span>`). Es enthält **keinen** generellen HTML-Sanitizer (kein bleach, lxml-cleaner, nh3 o.ä.).
Felder in `RICH_HTML_EXERCISE_FIELDS` (`summary`, `goal`, `execution`, `preparation`, `trainer_notes`) können beliebiges HTML enthalten. Risikominderung:
- CSP `script-src 'self'` verhindert `<script>`-Ausführung
- React's `dangerouslySetInnerHTML` muss im Frontend für XSS genutzt werden — zu prüfen
- Betroffene Felder sind nur für eingeloggte Nutzer sichtbar (kein öffentlicher Angriffspfad)
**Empfehlung:** bleach oder nh3 für Allowlist-basierte HTML-Sanitierung einsetzen.
#### LoginPage.jsx — Weitere Befunde
1. **Keine Rechtstexte-Links:** Kein Link auf Impressum oder Datenschutzerklärung (bestätigt KRIT-01)
2. **`minLength="6"`:** Browser-seitig 6 Zeichen, Backend erzwingt 8 → UX-Bruch, Nutzer sieht kein Frontend-Feedback
3. **Hartcodierter Versionsstring:** `v0.1.0 • Development` ist öffentlich sichtbar, falsch (App ist 0.8.65) und leakt Umgebungsinfo
### 13.5 Warnung: ALLOW_PUBLIC_MEDIA_STATIC (SEC-05)
Das Flag `ALLOW_PUBLIC_MEDIA_STATIC=1` würde bei Aktivierung alle Mediendateien ohne Auth unter `/media/` ausliefern und das gesamte Sichtbarkeitskonzept (privat, Verein, offiziell) für alle gespeicherten Mediendateien unterlaufen. Bestätigt in `backend/main.py:222-223`:
```python
if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
```
**Dieses Flag darf in Produktionsumgebungen niemals gesetzt sein.** Muss in der Betriebsdokumentation explizit verboten und per Release-Checkliste überprüft werden.
---
## 14. Dokumentationsanalyse
### 14.1 Vorhandene technische Dokumentation
- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
- `backend/version.py` mit CHANGELOG ✓
- `backend/scripts/check_access_layer_hints.py`
### 14.2 Fehlende rechtliche Dokumentation
| Dokument | Status (Audit 2026-05-09) | Stand 2026-05-10 |
|----------|--------------------------|------------------|
| Impressum | ❌ Keine Seite, kein Inhalt, keine Route | ⚠️ Route + Platzhalter vorhanden; Inhalt durch Betreiber/Rechtsanwalt ausstehend |
| Datenschutzerklärung | ❌ | ⚠️ Route + Platzhalter vorhanden; Inhalt ausstehend |
| Nutzungsbedingungen / AGB | ❌ | ⚠️ Route + Platzhalter vorhanden; Inhalt ausstehend |
| Medienrichtlinie / Community-Regeln | ❌ | ⚠️ Route + Platzhalter vorhanden; Inhalt ausstehend |
| Verzeichnis der Verarbeitungstätigkeiten (VVT) | ❌ | ❌ offen (Betreiber-Aufgabe) |
| AV-Verträge mit Auftragsverarbeitern | ❌ | ❌ offen (Betreiber-Aufgabe) |
| Backup-Konzept schriftlich | ❌ | ❌ offen (Betreiber-Aufgabe) |
> **Amendment 2026-05-10:** Routen und Platzhalterseiten implementiert (P-01, 0.8.69); Mobile/PWA via `/settings/legal` (P-01b, 0.8.70); Admin-konfigurierbare Rechtstexte mit DB-Versionierung, Superadmin-UI, PDF-Download, Abschnitts-Editor (P-01c, 0.8.710.8.74). KRIT-01 bleibt offen bis zur Veröffentlichung juristisch geprüfter Inhalte.
### 14.3 Fehlende Frontend-Routen
In `frontend/src/App.jsx` fehlen (Stand Audit 2026-05-09):
- `/impressum`
- `/datenschutz`
- `/nutzungsbedingungen`
- `/medienrichtlinie`
Alle müssen **vor der Authentifizierung** erreichbar und in der PWA verfügbar sein.
> **Amendment 2026-05-10 (P-01, Version 0.8.69):** Alle vier Routen sind implementiert und ohne Auth erreichbar. Links in LoginPage-Footer, DesktopSidebar-Footer und `/settings/legal` (P-01b). Migrationsnummer der Datenbank: bis 046 geprüft (Audit-Scope). Migration 047 (legal_documents) wurde außerhalb des Audit-Zeitfensters angelegt — siehe `docs/compliance-implementation.md` §P-01c.
---
## 15. Testanalyse
### 15.1 Vorhandene Tests
| Test-Datei | Inhalt |
|-----------|--------|
| `test_access_layer.py` | Sichtbarkeits-SQL-Logik |
| `test_access_layer_integration.py` | Cross-Tenant-Isolation mit echter DB |
| `test_exercises_delete_policy.py` | DELETE-Logik |
| `test_exercise_media_download.py` | Download-Zugriffsschutz |
| `test_official_exercise_media_rules.py` | Official-Promotion + Copyright |
| `test_media_assets_archive.py` | Deduplizierung, Lifecycle |
| `test_club_exercise_media_copyright.py` | Copyright bei Vereinsübungen |
| `test_exercise_inline_post.py` | Inline-Medien-Validierung |
| `test_exercise_rich_text.py` | HTML-Sanitizer |
| `test_security_release.py` | OpenAPI/Health in Produktion |
| `test_profiles_read_access.py` | Profil-Zugriffsrechte |
### 15.2 Testlücken
| Bereich | Status |
|---------|--------|
| DSA-Meldeverfahren | ❌ Funktion fehlt |
| DSGVO-Betroffenenrechte (Löschung, Export) | ❌ Funktion fehlt |
| Minderjährigen-Check beim Upload | ❌ Funktion fehlt |
| Einwilligungs-Check beim Upload | ❌ Funktion fehlt |
| ALLOW_PUBLIC_MEDIA_STATIC=1 aktiviert | ❌ Nicht getestet |
| Admin-Audit-Log | ❌ Funktion fehlt |
| Passwort-Mindestlänge bei PIN-Änderung | ❌ Nicht getestet |
| Copyright-Pflicht bei Archiv-Promotion | ❌ Nicht getestet |
---
## 16. Risiko-Matrix
### KRITISCH
| ID | Titel | Beschreibung | Betroffene Dateien | Juristisch prüfen | Aufwand |
|----|-------|-------------|-------------------|-------------------|---------|
| KRIT-01 | Keine Rechtstexte | Keine Impressum-, Datenschutz-, AGB- oder Medienrichtlinien-Seite. Öffentlich erreichbare App ohne Impressum ist Ordnungswidrigkeit (TMG §5, TDDDG). | `frontend/src/App.jsx` keine Routen | Ja | 25 Tage (Technik) + Rechtsanwalt |
| KRIT-02 | Kein Self-Service-Löschrecht | Nutzer können ihr Konto nicht selbst löschen (DSGVO Art. 17). Nur Plattform-Admin kann Konten löschen. | `backend/routers/profiles.py:414` | Ja | 58 Tage |
| KRIT-03 | Kein DSA-Meldeverfahren | Keine Möglichkeit, rechtswidrige Inhalte zu melden. Kein Moderationssystem für UGC-Plattform. | Kein Router vorhanden | Ja | 1020 Tage |
| KRIT-04 | Kein Recht-am-eigenen-Bild-Check | Keine Abfrage bei Medienupload, ob Personen oder Minderjährige abgebildet sind. | `backend/routers/exercises.py`, `backend/routers/media_assets.py` | Ja | 37 Tage |
| KRIT-05 | Kein DSGVO-Self-Service (Auskunft, Export) | Keine Datenauskunft, kein Datenexport, keine Berichtigungsmöglichkeit (DSGVO Art. 15, 16, 20). | Kein Endpoint | Ja | 510 Tage |
| KRIT-06 | Copyright-Pflicht inkonsistent | Copyright ist Pflichtfeld nur im exercises-Router für `official`. Im media_assets-Router (Archiv) kann ohne Copyright zu `club`/`official` promoted werden. | `backend/routers/media_assets.py:766784` | Ja | 12 Tage |
| KRIT-07 | Papierkorb-Job nicht geplant | `media_retention_job.py` existiert, aber kein Cron-Job konfiguriert. Medien bleiben physisch nach Ablauf der DSGVO-Fristen auf dem Server. | `backend/media_retention_job.py` | Nein | 1 Tag |
### HOCH
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| HOCH-01 | ALLOW_PUBLIC_MEDIA_STATIC | Wenn gesetzt, sind alle Mediendateien ohne Auth abrufbar untergräbt gesamtes Sichtbarkeitskonzept | Dokumentation + Test |
| HOCH-02 | Kein HSTS | Kein `Strict-Transport-Security`-Header in nginx.conf | Dokumentation (Reverse-Proxy) |
| HOCH-03 | Auth-Token in localStorage | XSS könnte Token extrahieren; CSP reduziert, eliminiert nicht | Dokumentation (HttpOnly als Langfrist-Plan) |
| HOCH-04 | Kein MFA für Superadmins | Superadmin hat unbegrenzten Systemzugriff ohne zweiten Faktor | 58 Tage |
| HOCH-05 | Kein Admin-Audit-Log | Profil-Löschungen, Lifecycle-Aktionen nicht geloggt | 35 Tage |
| HOCH-06 | Keine Mindestalter-Abfrage | Keine Schranke gegen Registrierung Minderjähriger | 12 Tage |
### MITTEL
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| MITT-01 | Passwort-Mindestlänge inkonsistent | Register: 8 Zeichen, `PUT /api/auth/pin`: 4 Zeichen | 30 Min |
| MITT-02 | Keine Sofortsperrung bei Rechtsverletzung | Stufe-1-Papierkorb dauert 30 Tage bis zur vollständigen Unsichtbarkeit | 23 Tage |
| MITT-03 | Kein VVT | Kein Verzeichnis der Verarbeitungstätigkeiten (DSGVO Art. 30) | Betreiber |
| MITT-04 | SMTP-Anbieter unbekannt | Kein AV-Vertrag; E-Mail-Dienstleister nicht dokumentiert | Betreiber |
| MITT-05 | sessionStorage nicht bei Logout bereinigt | TrainingCoachPage-Fortschritt bleibt nach Logout im sessionStorage | 0,5 Tage |
| MITT-06 | Keine Löschung aus Backups | DSGVO-Löschungsanfragen greifen nicht auf Backups | Betreiber (Policy) |
| MITT-07 | MediaWiki-Integration ohne AV-Vertrag | Datentransfer zu karatetrainer.net nicht dokumentiert | Betreiber |
| MITT-08 | OpenRouter ohne AV-Vertrag | Erst aktivieren wenn AV-Vertrag vorhanden | Betreiber |
### NIEDRIG
| ID | Titel | Beschreibung | Aufwand |
|----|-------|-------------|---------|
| NIED-01 | `style-src 'unsafe-inline'` | Inline-Styles in CSP erlaubt | Nonce/Hash |
| NIED-02 | Kein Anti-Virus-Scan | Malware-Dateien hochladbar | ClamAV-Integration |
| NIED-03 | Reset-Token in sessions-Tabelle | Token mit `reset_`-Präfix in Auth-Tabelle | Separate Tabelle |
| NIED-04 | SHA256-Hash in API-Response | Datei-Fingerprinting möglich | Response-Filterung |
| NIED-05 | Kein Passwort-Complexity-Check | Nur Mindestlänge geprüft | zxcvbn o.ä. |
| NIED-06 | `minLength="6"` im Login-Formular | Inkonsistent mit Backend (8 Zeichen); Browser lässt 6-7-char-Passwörter zu, Backend lehnt sie dann ab | `frontend/src/pages/LoginPage.jsx:175` |
| NIED-07 | Hartcodierter Versionsstring auf Login-Seite | `v0.1.0 • Development` sichtbar ohne Auth; falsche Version (0.8.65) + Umgebungsinfo | `frontend/src/pages/LoginPage.jsx:242` |
| NIED-08 | CORS allow_methods/headers=`["*"]` | Breiter als nötig; Origins sind korrekt eingeschränkt, aber Methoden/Header nicht | `backend/main.py:84-87` |
| NIED-09 | Kein genereller HTML-Sanitizer für Rich-Text | `exercise_rich_text.py` bereinigt nur Inline-Media-Markup; beliebiges HTML in `summary`, `goal`, `execution` etc. möglich (CSP schützt gegen Script-Execution) | `backend/exercise_rich_text.py` |
---
## 17. Umsetzungsplan
### Empfohlene Reihenfolge
**Etappe 1 Pflicht vor öffentlichem Betrieb (Kritische Blocker)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-01 | Rechtstexte (Seiten + Routen, Inhalte durch Rechtsanwalt) | KRIT-01 | 25 Tage Technik |
| P-02 | Self-Service-Kontolöschung + Datenexport | KRIT-02, KRIT-05 | 58 Tage |
| P-03 | Papierkorb-Retention-Job aktivieren | KRIT-07 | 1 Tag |
| P-04 | Copyright-Pflicht für Archiv-Promotion vereinheitlichen | KRIT-06 | 1 Tag |
| P-05 | Passwort-Mindestlänge angleichen | MITT-01 | 30 Min |
| P-06 | Upload-Einwilligungsdialog (Personen, Minderjährige, Rechte) | KRIT-04 | 24 Tage |
**Etappe 2 Sicherheit und Datenschutz (dringend empfohlen)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-07 | ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Test | HOCH-01, SEC-05 | 0,5 Tage |
| P-08 | HSTS und externe Proxy-Sicherheit dokumentieren | HOCH-02, SEC-01 | 0,5 Tage |
| P-09 | Admin-Audit-Log | HOCH-05, SEC-07 | 35 Tage |
| P-10 | Mindestalter-Abfrage | HOCH-06 | 12 Tage |
| P-11 | Legal-Hold Lifecycle-Status | MITT-02 | 23 Tage |
| P-12 | sessionStorage bei Logout bereinigen | MITT-05 | 0,5 Tage |
| P-22 | HTML-Sanitizer für Rich-Text-Felder (bleach/nh3) | NIED-09, SEC-11 | 12 Tage |
| P-23 | LoginPage: minLength angleichen + Version entfernen | NIED-06, NIED-07, SEC-12, SEC-13 | 1 Stunde |
| P-24 | CORS einschränken (Methoden + Header) | NIED-08, SEC-14 | 1 Stunde |
**Etappe 3 DSA-Meldeverfahren (mittelfristig, nach juristischer Klärung)**
| Paket-ID | Titel | Findings | Aufwand |
|----------|-------|---------|---------|
| P-13 | Content-Melde-Backend (content_reports-Tabelle + Endpoints) | KRIT-03 | 58 Tage |
| P-14 | Moderations-UI (Frontend) | KRIT-03 | 35 Tage |
| P-15 | Uploader-Benachrichtigung bei Sperrung | KRIT-03 | 12 Tage |
| P-16 | Beschwerdeverfahren | KRIT-03 | 24 Tage |
**Etappe 4 Langfristige Optimierungen**
| Paket-ID | Titel | Aufwand |
|----------|-------|---------|
| P-17 | MFA für Superadmins (TOTP) | 58 Tage |
| P-18 | HttpOnly-Cookie als Auth-Alternative | 35 Tage |
| P-19 | Anti-Virus-Scan (ClamAV) | 35 Tage |
| P-20 | VVT erstellen | Betreiber |
| P-21 | AV-Verträge abschließen | Betreiber |
---
### P-04 im Detail: Copyright-Pflicht vereinheitlichen
**Technische Änderung Backend:**
In `backend/routers/media_assets.py`, Funktionen `patch_media_asset()` und `bulk_media_patch()`:
- Bei Visibility-Wechsel zu `club` oder `official`: `copyright_notice` muss vorhanden und min. 3 Zeichen lang sein
- Fehler: HTTP 422 mit klarer Fehlermeldung
**Test:** Promotion ohne copyright_notice → HTTP 422; mit copyright_notice → OK
---
### P-05 im Detail: Passwort-Mindestlänge angleichen
**Technische Änderung:**
- `backend/routers/auth.py:104`: `if len(new_pin) < 8:` statt `< 4`
- Fehlermeldung: „Passwort muss mindestens 8 Zeichen lang haben"
---
### P-13 im Detail: Content-Melde-Backend
**Neue DB-Migration (`backend/migrations/047_content_reports.sql`):**
```sql
CREATE TABLE content_reports (
id SERIAL PRIMARY KEY,
reporter_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
target_type VARCHAR(20) NOT NULL CHECK (target_type IN ('exercise', 'media_asset')),
target_id INTEGER NOT NULL,
reason VARCHAR(50) NOT NULL
CHECK (reason IN ('illegal_content', 'copyright', 'personal_rights',
'minor_protection', 'hate_speech', 'spam', 'other')),
description TEXT,
status VARCHAR(20) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'under_review', 'resolved_removed',
'resolved_kept', 'rejected')),
moderator_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
moderator_note TEXT,
resolved_at TIMESTAMP,
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW()
);
```
**Neue Endpoints:**
- `POST /api/reports` Meldung einreichen (require_auth)
- `GET /api/admin/reports` Moderations-Queue (Admin/Superadmin)
- `PATCH /api/admin/reports/{id}` Status setzen + Notiz
---
## 18. Dauerhafter Auditplan
### 18.1 Audit-Frequenzen
| Typ | Frequenz |
|-----|----------|
| Mini-Audit | Bei jedem PR mit Compliance-Relevanz |
| Release-Audit | Vor jedem Produktions-Deployment |
| Quartalsaudit | Alle 3 Monate |
| Jahresaudit | Einmal jährlich (mit Rechtsanwalt/Datenschutzbeauftragtem) |
### 18.2 Sonderaudit-Auslöser
- Neue öffentlich sichtbare Features
- Neue Upload-Funktionen (Medientypen, Quellen)
- Neue Rollen oder Rechteänderungen
- Neue externe Dienstleister
- Einführung von Analytics oder Tracking
- Änderungen am Minderjährigenschutz
- Sicherheitsvorfall oder Datenpanne
- Datenschutzanfrage eines Nutzers
- Meldung rechtswidriger Inhalte
- Größere Architekturänderungen
- Gesetzesänderungen (DSGVO, DSA, TDDDG)
### 18.3 Checkliste: Mini-Audit bei Feature-PR
```
[ ] Betrifft der PR personenbezogene Daten? → DSGVO-Check
[ ] Betrifft der PR Medienupload oder -anzeige? → Rechte-Check + DSA-Check
[ ] Betrifft der PR Rollen oder Sichtbarkeit? → Access-Layer-Check
[ ] Betrifft der PR Storage oder Caching? → TDDDG-Check
[ ] Betrifft der PR externe Dienste? → AV-Vertrag prüfen
[ ] Betrifft der PR Auth oder Session? → Sicherheits-Check
[ ] Neuer Endpoint → require_auth / get_tenant_context vorhanden?
[ ] Neue Datenspalte → Migration + Lösch-Cascade korrekt?
[ ] Neue Frontend-Speicherung → TDDDG-Klassifikation dokumentiert?
[ ] ACCESS_LAYER: python backend/scripts/check_access_layer_hints.py
```
### 18.4 Checkliste: Release-Audit
```
[ ] Versions-Bump (backend/version.py + frontend/src/version.js)
[ ] ALLOW_PUBLIC_MEDIA_STATIC nicht in Prod-Env gesetzt
[ ] OpenAPI/Swagger nicht öffentlich (ENVIRONMENT=production)
[ ] HSTS am externen Reverse-Proxy konfiguriert (manuell prüfen)
[ ] Papierkorb-Retention-Job läuft (Logs prüfen)
[ ] SSL-Zertifikat gültig
[ ] DB-Backup der letzten 24h vorhanden
[ ] Rechtstexte aktuell (kein Placeholder, kein veraltetes Datum)
[ ] pytest -m "not slow" grün
[ ] ACCESS_LAYER_INTEGRATION=1 pytest tests/test_access_layer_integration.py grün
[ ] test_security_release.py grün
[ ] pip-audit (keine kritischen CVEs)
[ ] npm audit --audit-level=high (keine kritischen CVEs)
```
### 18.5 Checkliste: Neues Feature
```
[ ] Verarbeitet das Feature personenbezogene Daten? → VVT aktualisieren
[ ] Neue DB-Tabellen → Lösch-Cascade und Retention definiert?
[ ] Neue API-Endpoints → Access Layer korrekt?
[ ] Feature mit UGC? → DSA-Meldeverfahren abgedeckt?
[ ] Feature mit Medien? → Lifecycle, Sichtbarkeit, Copyright?
[ ] Feature für Minderjährige relevant? → Schutzmaßnahmen?
[ ] Neue localStorage/sessionStorage-Nutzung → TDDDG + Datenschutzerklärung?
[ ] Neue externe Abhängigkeit → AV-Vertrag?
[ ] Keine sensitiven Daten in Fehlerausgaben?
[ ] api.js für alle Frontend API-Calls?
[ ] Tests geschrieben und grün?
```
### 18.6 Checkliste: Änderung am Medienmodell
```
[ ] Neue Medientypen → MIME-Type-Validierung aktualisiert?
[ ] Neue Sichtbarkeitsstufen → library_content_visibility_sql() aktualisiert?
[ ] Lifecycle-Änderungen → Papierkorb-Job angepasst?
[ ] Copyright-Anforderungen konsistent für alle Promotions-Wege?
[ ] ALLOW_PUBLIC_MEDIA_STATIC explizit: In Prod verboten (dokumentiert)?
[ ] Neue Download-Endpoints → require_auth_flexible implementiert?
[ ] Recht-am-eigenen-Bild-Abfragen aktualisiert?
```
### 18.7 Checkliste: Änderung am Rollenmodell
```
[ ] Neue Rollen → assert_valid_governance_visibility() berücksichtigt?
[ ] EXEMPT-Liste in check_access_layer_hints.py aktualisiert?
[ ] test_access_layer*.py angepasst?
[ ] MFA-Anforderung für neue Admin-Rollen geprüft?
[ ] Audit-Log für neue Aktionen?
```
### 18.8 Pflichttests vor Release
```bash
# Zugriffskontrolle
pytest backend/tests/test_access_layer.py -v
# Cross-Tenant-Integration (mit PostgreSQL)
ACCESS_LAYER_INTEGRATION=1 pytest backend/tests/test_access_layer_integration.py -v
# Mediensicherheit
pytest backend/tests/test_exercise_media_download.py -v
pytest backend/tests/test_official_exercise_media_rules.py -v
pytest backend/tests/test_media_assets_archive.py -v
pytest backend/tests/test_club_exercise_media_copyright.py -v
# Sicherheits-Release-Checks
pytest backend/tests/test_security_release.py -v
# Access Layer Script (Strict Mode)
ACCESS_LAYER_STRICT=1 python backend/scripts/check_access_layer_hints.py
# Abhängigkeits-Scan
cd backend && pip-audit
cd frontend && npm audit --audit-level=high
```
### 18.9 Vorlage: Quartalsaudit-Bericht
```markdown
# Compliance-Quartalsaudit Q[X]/[Jahr]
Datum: [DATUM] | Auditor: [NAME]
## 1. Status offener Findings
[Tabelle: ID Titel Status]
## 2. Neue Findings dieser Periode
[Tabelle: ID Titel Schwere Aufwand]
## 3. Rechtstexte
[ ] Impressum geprüft: [DATUM]
[ ] Datenschutzerklärung geprüft: [DATUM]
[ ] AGB/Nutzungsbedingungen geprüft: [DATUM]
[ ] Medienrichtlinie geprüft: [DATUM]
## 4. DSGVO-Status
[ ] VVT aktuell
[ ] AV-Verträge vollständig
[ ] Backup-Retention-Policy dokumentiert
[ ] Betroffenenrechte-Mechanismen getestet
## 5. DSA-Status
[ ] Meldeverfahren Test-Meldung durchgeführt: [DATUM]
[ ] Offene Meldungen: [X] (alle bearbeitet: Ja/Nein)
[ ] Eskalationspfad dokumentiert: Ja/Nein
## 6. Technische Sicherheit
[ ] Abhängigkeits-Scan: [DATUM] Kritische CVEs: [X]
[ ] SSL gültig bis: [DATUM]
[ ] HSTS konfiguriert: Ja/Nein
[ ] Alle Pflichttests grün: Ja/Nein
## 7. Empfehlungen
[Liste]
```
---
## 19. Entscheidungsvorlage
### 19.1 Was ist aktuell kritisch?
1. Keine Rechtstexte App ist öffentlich ohne Impressum (Ordnungswidrigkeit)
2. Kein Löschrecht für Nutzer DSGVO Art. 17 verletzt
3. Kein DSA-Meldeverfahren bei UGC-Plattform möglicherweise Pflicht
4. Copyright-Lücke Archiv-Upload ohne Copyright-Pflicht bei Promotion
5. Kein Recht-am-eigenen-Bild-Check bei Upload
### 19.2 Was blockiert sicheren öffentlichen Betrieb (technisch)?
1. KRIT-01: Rechtstexte fehlen vollständig
2. KRIT-02: Kein Self-Service-Löschrecht
3. KRIT-07: Papierkorb-Job läuft nicht automatisch
4. HOCH-01: ALLOW_PUBLIC_MEDIA_STATIC muss in Prod-Doku explizit verboten sein
5. HOCH-02: HSTS am externen Reverse-Proxy überprüfen
### 19.3 Mindest-Paket vor erstem öffentlichem Betrieb
- P-05 (30 Min): Passwort-Mindestlänge angleichen
- P-04 (1 Tag): Copyright-Pflicht vereinheitlichen
- P-03 (1 Tag): Papierkorb-Job aktivieren
- P-06 (2 Tage): Upload-Einwilligungsdialog
- P-01 (2 Tage Technik + Rechtsanwalt): Rechtstexte-Seiten
- P-02 (5 Tage): Self-Service-Kontolöschung
### 19.4 Was kann zurückgestellt werden?
- P-17 (HttpOnly-Cookie): Größere Architekturänderung, kein unmittelbarer Compliance-Bedarf
- P-19 (Anti-Virus-Scan): Hoher Aufwand, Risiko bei lokalem Storage gering
- P-13 bis P-16 (DSA-Meldeverfahren): Erst juristisch klären ob und in welchem Umfang erforderlich
### 19.5 Juristisch zu prüfende Fragen
| Thema | Frage |
|-------|-------|
| DSGVO Art. 17 | Wie muss Löschung aus Backups gehandhabt werden? |
| DSA | Ab welcher Nutzerzahl gilt der DSA? Welche Pflichten für Kleinplattformen? |
| §22 KUG | Welche Einwilligungen für Personenbilder im Vereinskontext? |
| §8 DSGVO | Mindestalter für die Registrierung? |
| TDDDG §25 | Muss für localStorage eine Einwilligung eingeholt werden? |
| Impressum-Pflicht | Vollständige Angaben des Verantwortlichen? |
| AV-Verträge | Welche Dienstleister benötigen einen AVV? |
| MediaWiki | Lizenzanforderungen für Übungsinhalte aus karatetrainer.net? |
### 19.6 Organisatorische Aufgaben für den Betreiber
1. Rechtsanwalt beauftragen (Rechtstexte)
2. VVT erstellen (DSGVO Art. 30)
3. AV-Verträge abschließen (SMTP-Anbieter, ggf. MediaWiki)
4. Backup-Prozess dokumentieren und Restore-Tests durchführen
5. Moderationsprozess definieren
6. Notfallkontakt für Datenpannen benennen
7. HSTS am externen Reverse-Proxy sicherstellen
8. Papierkorb-Job-Monitoring einrichten
### 19.7 Verbleibende Risiken nach technischer Umsetzung
- HSTS liegt außerhalb des Repos (Betreiber-Verantwortung)
- Backup-Löschung bei DSGVO-Anfragen erfordert manuelle Prozesse
- Minderjährige können Altersangabe fälschen (kein verlässlicher Online-Altersnachweis)
- Rechtswidrige Inhalte können zwischen Upload und Moderationsentscheidung sichtbar sein
- SMTP-Anbieter kann E-Mail-Inhalte verarbeiten
### 19.8 Freigabe-Formulierungen
Verwende diese exakten Formulierungen zur Freigabe einzelner Pakete:
| Paket | Freigabe-Formulierung |
|-------|----------------------|
| P-05 | „Freigabe zur Umsetzung P-05: Passwort-Mindestlänge angleichen" |
| P-04 | „Freigabe zur Umsetzung P-04: Copyright-Pflicht vereinheitlichen" |
| P-03 | „Freigabe zur Umsetzung P-03: Papierkorb-Retention-Job aktivieren" |
| P-06 | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| P-01 | „Freigabe zur Umsetzung P-01: Rechtstexte-Seiten technisch anlegen" |
| P-02 | „Freigabe zur Umsetzung P-02: Self-Service-Kontolöschung und Datenexport" |
| P-09 | „Freigabe zur Umsetzung P-09: Admin-Audit-Log" |
| P-13 | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
| Etappe 1 komplett | „Freigabe zur Umsetzung Etappe 1" |
| Etappe 1+2 | „Freigabe zur Umsetzung Etappen 1 und 2" |
---
**Audit abgeschlossen. Keine Codeänderungen vorgenommen. Umsetzung erst nach ausdrücklicher Freigabe.**
---
## 20. Regel zur Paket-ID-Stabilität
> Ergänzt: 2026-05-10 — Kanonisches Referenzdokument: `docs/compliance-package-register.md`
### 20.1 Grundsatz
Paket-IDs (P-01 bis P-24 und alle künftigen) werden nach ihrer ersten Vergabe in diesem Dokument **nie wieder umnummeriert, gelöscht oder wiederverwendet.** Die in §17 (Umsetzungsplan) vergebenen IDs sind dauerhaft stabil.
### 20.2 Regeln im Einzelnen
| Regel | Beschreibung |
|-------|-------------|
| **ID-Stabilität** | Eine einmal vergebene Paket-ID bleibt für immer mit dem ursprünglichen fachlichen Inhalt verknüpft. |
| **Titel-Präzisierung erlaubt** | Der Titel eines Pakets darf präzisiert werden, wenn die fachliche Substanz unverändert bleibt. Die ID ändert sich dabei nicht. |
| **Neue Pakete** | Künftige Arbeitspakete erhalten aufsteigende neue IDs (P-25, P-26 …). Keine Wiedervergabe von IDs abgeschlossener oder gelöschter Pakete. |
| **Nacharbeiten mit Suffix** | Nacharbeiten, Korrekturen oder Teilprobleme eines bestehenden Pakets werden mit alphabetischen Suffixen dokumentiert (P-03b, P-05b, …), nicht als eigenständige Hauptpakete. |
| **Freigabe-Formulierung** | Freigaben müssen immer die Paket-ID **und** den kanonischen Titel aus diesem Dokument oder aus `docs/compliance-package-register.md` nennen, um Verwechslungen auszuschließen. |
| **Kanonisches Register** | `docs/compliance-package-register.md` ist die verbindliche Quelle für alle Umsetzungsberichte, Re-Audit-Dokumente und Freigaben. Bei Widerspruch zwischen Dokumenten gilt dieses Audit als ursprüngliche Quelle; das Register als aktuell gepflegte Wahrheit. |
### 20.3 Umgang mit Drift in nachgelagerten Dokumenten
Falls ein nachgelagertes Dokument (Umsetzungsbericht, Re-Audit, Mini-Fix) eine abweichende Beschreibung für eine bekannte ID verwendet, gilt:
1. Die abweichende Beschreibung ist ein **Dokumentationsfehler**, kein neues Paket.
2. Das betroffene Dokument ist auf die kanonische ID und den kanonischen Titel zu korrigieren.
3. Der Drift und die Korrektur sind im Konsistenzbericht des Paketregisters zu vermerken.
4. Kein fachlicher Inhalt darf bei der Korrektur verloren gehen — er wird ggf. dem richtigen Paket-Eintrag zugeordnet.
### 20.4 Freigabe-Vorlagen (aktualisiert)
Verwende diese exakten Formulierungen zur Freigabe einzelner Pakete:
| Paket | Freigabe-Formulierung |
|-------|----------------------|
| P-01 | „Freigabe zur Umsetzung P-01: Rechtstexte" |
| P-02 | „Freigabe zur Umsetzung P-02: Self-Service-Kontolöschung und Datenexport" |
| P-03 | „Freigabe zur Umsetzung P-03: Papierkorb-Retention-Job aktivieren" |
| P-04 | „Freigabe zur Umsetzung P-04: Copyright-Pflicht für Archiv-Promotion vereinheitlichen" |
| P-05 | „Freigabe zur Umsetzung P-05: Passwort-Mindestlänge angleichen" |
| P-06 | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| P-07 | „Freigabe zur Umsetzung P-07: ALLOW_PUBLIC_MEDIA_STATIC dokumentieren und testen" |
| P-08 | „Freigabe zur Umsetzung P-08: HSTS und externe Proxy-Sicherheit dokumentieren" |
| P-09 | „Freigabe zur Umsetzung P-09: Admin-Audit-Log" |
| P-10 | „Freigabe zur Umsetzung P-10: Mindestalter-Abfrage" |
| P-11 | „Freigabe zur Umsetzung P-11: Legal-Hold Lifecycle-Status" |
| P-12 | „Freigabe zur Umsetzung P-12: sessionStorage bei Logout bereinigen" |
| P-13 | „Freigabe zur Umsetzung P-13: Content-Melde-Backend" |
| P-17 | „Freigabe zur Umsetzung P-17: MFA für Superadmins" |
| P-18 | „Freigabe zur Umsetzung P-18: HttpOnly-Cookie als Auth-Alternative" |
| P-22 | „Freigabe zur Umsetzung P-22: HTML-Sanitizer für Rich-Text-Felder" |
| Etappe 1 komplett | „Freigabe zur Umsetzung Etappe 1" |
| Etappe 1+2 | „Freigabe zur Umsetzung Etappen 1 und 2" |
> Die ursprüngliche Freigabe-Tabelle in §19.8 bleibt erhalten und zeigt den Stand des Initial-Audits. §20.4 ist die aktuellere, vollständigere Version.
---
*Dokument erstellt: 2026-05-09 | Auditor: Claude Code | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*

View File

@ -0,0 +1,662 @@
# Compliance-Implementierung Umsetzungsbericht
**Erstellt:** 2026-05-09
**Zuletzt aktualisiert:** 2026-05-11
**Audit-Basis:** `docs/compliance-audit.md`
**App-Version nach Umsetzung:** 0.8.94
---
## Freigegebene Pakete und Umsetzungsstatus
### P-01 Rechtstexte ⚠️ (technischer Teil umgesetzt; juristische Inhalte offen)
**Status:** Technisch teilweise umgesetzt (2026-05-10, Version 0.8.69)
**Betroffene Dateien:**
- `frontend/src/pages/LegalPage.jsx` (neu) — Platzhalter-Komponente für alle vier Seiten
- `frontend/src/App.jsx` — 4 neue öffentliche Routen
- `frontend/src/pages/LoginPage.jsx` — Rechtstext-Links im Card-Footer
- `frontend/src/components/DesktopSidebar.jsx` — Rechtstext-Links im Sidebar-Footer
**Technische Änderung:**
Vier öffentliche Routen angelegt, kein Auth erforderlich, kein Redirect für nicht eingeloggte Nutzer:
- `/impressum``<LegalPage type="impressum" />`
- `/datenschutz``<LegalPage type="datenschutz" />`
- `/nutzungsbedingungen``<LegalPage type="nutzungsbedingungen" />`
- `/medienrichtlinie``<LegalPage type="medienrichtlinie" />`
Jede Seite enthält:
- Deutlich sichtbaren Platzhalterhinweis: „MUSTER / PLATZHALTER Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt."
- Strukturierte Pflichtfelder je Rechtstext (Betreiber, Rechtsgrundlagen, Betroffenenrechte etc.)
- Querlinks zu den anderen drei Rechtstextseiten
Links zu allen vier Seiten sind in der Login-/Registrierungsseite (Card-Footer) und im Desktop-Sidebar-Footer sichtbar ohne Anmeldung erreichbar.
**Nicht umgesetzt (außerhalb Freigabe):**
Juristische Texte, inhaltliche Überprüfung, Admin-konfigurierbare Inhalte, Einwilligungs-Checkboxen.
**KRIT-01 Blocking-Status:**
Der Blocker KRIT-01 bleibt **offen** bis juristisch geprüfte Texte durch den Betreiber eingepflegt sind. Die technische Voraussetzung (Routen und Seitenstruktur) ist erfüllt.
**Tests:** 5 Playwright-Tests, alle grün:
- Impressum ohne Auth erreichbar + Platzhalterhinweis sichtbar + Reload OK
- Datenschutz ohne Auth erreichbar + Platzhalterhinweis sichtbar + Reload OK
- Nutzungsbedingungen ohne Auth erreichbar + Platzhalterhinweis sichtbar + Reload OK
- Medienrichtlinie ohne Auth erreichbar + Platzhalterhinweis sichtbar + Reload OK
- Login-Seite enthält alle vier Rechtstext-Links
---
### P-01b Mobile/PWA-Erreichbarkeit der Rechtstexte ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.70) — Nacharbeit zu P-01
**Betroffene Dateien:**
- `frontend/src/pages/SettingsLegalPage.jsx` (neu) — Hub-Seite `/settings/legal`
- `frontend/src/App.jsx` — Route `/settings/legal` im ProtectedLayout
- `frontend/src/pages/AccountSettingsPage.jsx` — Link zu `/settings/legal`
**Technische Änderung:**
Neue Seite `/settings/legal` im eingeloggten Einstellungsbereich: Hub-Seite mit Links zu allen vier Rechtstextseiten, erreichbar über Einstellungen → Rechtliches. Entspricht dem bestehenden Pattern von `/settings/system`. Auf der AccountSettingsPage ist ein Link „Rechtliches" unterhalb der System-Info-Verlinkung ergänzt.
**Tests:** 3 Playwright-Tests, alle grün (17/17 Gesamt):
- Einstellungen enthält Link zu `/settings/legal`
- `/settings/legal` enthält Überschrift + alle vier Rechtstext-Links
- Jeder Link aus `/settings/legal` führt zur korrekten öffentlichen Route
---
### P-01c Admin-konfigurierbare Rechtstexte ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.71) — Nacharbeit zu P-01
**Betroffene Dateien:**
- `backend/migrations/047_legal_documents.sql` (neu) — Tabellen `legal_documents` + `legal_document_audit`
- `backend/routers/legal_documents.py` (neu) — Öffentliche + Superadmin-Endpoints
- `backend/main.py` — Router-Registrierung
- `frontend/src/pages/LegalPage.jsx` — API-Fetch mit Fallback auf statischen Platzhalter
- `frontend/src/pages/AdminLegalDocumentsPage.jsx` (neu) — Superadmin-UI
- `frontend/src/App.jsx` — Route `/admin/legal-documents`
- `frontend/src/components/AdminPageNav.jsx` — Link „Rechtstexte" im Admin-Nav
- `frontend/src/utils/api.js` — 8 neue API-Funktionen
**Technische Änderung:**
**Datenbank (Migration 047):**
Tabelle `legal_documents`: versionierte Rechtstexte mit Workflow `draft → published → archived`. Felder: `document_type` (impressum | privacy_policy | terms_of_use | media_policy), `version` (INT, auto-inkrementiert), `title`, `content_sections` (JSONB: `[{heading, content}]`), `status`, `change_note`, Timestamps, FK auf Ersteller + Publisher. Partial-Unique-Index: nur ein `published`-Datensatz pro `document_type` gleichzeitig. Tabelle `legal_document_audit`: unveränderlicher Änderungslog je Dokument.
**Backend-Endpoints:**
| Endpoint | Auth | Beschreibung |
|----------|------|--------------|
| `GET /api/legal-documents/{type}/published` | Kein | Liefert veröffentlichtes Dokument oder `null` |
| `GET /api/admin/legal-documents` | Superadmin | Alle Versionen aller Typen |
| `POST /api/admin/legal-documents` | Superadmin | Neuen Entwurf anlegen |
| `GET /api/admin/legal-documents/{id}` | Superadmin | Einzeldokument mit `content_sections` |
| `PUT /api/admin/legal-documents/{id}` | Superadmin | Entwurf bearbeiten (nur `status=draft`) |
| `POST /api/admin/legal-documents/{id}/publish` | Superadmin | Veröffentlichen; bisherige Version → `archived` |
| `POST /api/admin/legal-documents/{id}/archive` | Superadmin | Archivieren |
| `GET /api/admin/legal-documents/{id}/audit` | Superadmin | Änderungslog |
**Frontend:**
`LegalPage.jsx` ruft beim Laden `GET /api/legal-documents/{type}/published` ab. Gibt die API `null` zurück (kein veröffentlichtes Dokument vorhanden), zeigt die Seite weiterhin den bisherigen Platzhalter mit MUSTER-Banner. Ist ein Dokument veröffentlicht, wird dessen Inhalt ohne Platzhalter-Banner angezeigt. `AdminLegalDocumentsPage.jsx` unter `/admin/legal-documents` (nur Superadmin) ermöglicht Erstellen, Bearbeiten, Veröffentlichen und Archivieren von Entwürfen mit Tabs pro Dokumententyp und Änderungslog.
**Kein neues npm-Paket notwendig** — JSONB-Struktur mit `{heading, content}` statt Markdown; keine XSS-Gefahr.
**Tests:** 3 Playwright-Tests:
- Rechtstextseiten laden ohne Fehler (API-fetch mit Fallback)
- `/admin/legal-documents` erreichbar für Superadmin mit korrekter Überschrift
- Admin-Nav enthält Link zu Rechtstexten
---
### P-01c Erweiterung — Als-Entwurf-kopieren ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.72) — Ergänzung zu P-01c
**Betroffene Dateien:**
- `backend/routers/legal_documents.py` — Neuer Endpoint `POST /api/admin/legal-documents/{id}/copy-as-draft`
- `frontend/src/utils/api.js``copyLegalDocumentAsDraft(id)`
- `frontend/src/pages/AdminLegalDocumentsPage.jsx` — „Als Entwurf kopieren"-Button in der Dokumentliste
**Technische Änderung:**
Neuer Superadmin-Endpoint kopiert `title` und `content_sections` eines bestehenden Dokuments in einen neuen Entwurf. Die Versionsnummer wird dabei automatisch inkrementiert (letztes Dokument dieses Typs + 1). Status ist immer `draft`. Ermöglicht inkrementelle Überarbeitung ohne vollständige Neueingabe.
**Motivation:** Bei jeder fälligen Textanpassung mussten alle Abschnitte neu erfasst werden. Die Kopierfunktion ermöglicht, den letzten Stand zu übernehmen und nur die geänderten Abschnitte zu bearbeiten.
---
### P-01c Erweiterung — Echter PDF-Download + Abschnitts-Sortierung ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.74) — Ergänzung zu P-01c
**Betroffene Dateien:**
- `frontend/src/pages/AdminLegalDocumentsPage.jsx``generateLegalPdf()` via jsPDF, `SectionEditor` mit Sortierung
- `frontend/src/pages/LegalPage.jsx``generateLegalPdf()` via jsPDF, Button „PDF herunterladen"
- `frontend/package.json` — neues npm-Paket `jspdf`
**Technische Änderung — PDF:**
Ersetzt die bisherige `window.open()` + `window.print()`-Lösung (Browser-Druckdialog) durch `jsPDF`. Die Funktion `generateLegalPdf(doc)` erzeugt ein A4-PDF client-seitig mit:
- Titel (bold, 20 pt), Metazeile (Version + Gültigkeitsdatum), Trennlinie
- Abschnitte mit Heading (bold, 11 pt) und Fließtext (10 pt, `splitTextToSize` für automatischen Zeilenumbruch)
- Automatischer Seitenumbruch bei `y > 277 mm`
- Footer auf jeder Seite: „Shinkan Jinkendo | Exportiert am DD.MM.YYYY Seite X von Y"
- Direkter Dateidownload via `pdf.save('{document_type}_v{version}.pdf')`
Gilt sowohl für die Admin-Seite (Download aus der Dokumentliste, ruft `getLegalDocument(id)` für Volldokument ab) als auch für `LegalPage.jsx` (öffentlich, nur bei veröffentlichten Dokumenten).
**Technische Änderung — Abschnitts-Sortierung und Einfügen:**
`SectionEditor` in `AdminLegalDocumentsPage.jsx` erhält:
- ▲/▼-Buttons pro Abschnitt (deaktiviert an den Rändern) — Reihenfolge per Array-Swap
- `InsertButton` zwischen jedem Abschnitt (inkl. vor dem ersten) — fügt leeren Abschnitt an beliebiger Stelle per `splice` ein
- Kein Drag-and-Drop-Framework — reine React-State-Manipulation
---
### P-03 Papierkorb-Retention-Job aktivieren ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `docker-compose.yml` neuer Service `retention-cron`
**Technische Änderung:**
Neuer Docker-Service `retention-cron` nutzt dasselbe Backend-Image und führt `scripts/media_retention_job.py` täglich um 03:00 Uhr aus. Der Service startet beim ersten Hochfahren sofort und schläft bis zum nächsten 3 AM (Python-basierter Loop ohne externe Cron-Abhängigkeit). Zugriff auf DB und Media-Volume identisch zur Backend-Konfiguration.
**Tests:** Keine automatisierten Tests möglich (Runtime-Verhalten); operativ über Container-Logs (`docker logs shinkan-retention-cron`) überprüfbar.
---
### P-03b Retention-Zeiten mit Löschkonzept abgleichen ✅
**Status:** Umgesetzt (2026-05-10)
**Betroffene Dateien:**
- `backend/media_lifecycle.py:24` Default `HIDDEN_TO_PURGE_DAYS`
- `docker-compose.yml` explizite Env-Variable im `retention-cron`-Service
**Befund des Re-Audits:**
Das fachliche Löschkonzept (Audit §6.4) sieht 30 + 30 Tage vor:
- Stufe 1 → Stufe 2 (Soft → Hidden): 30 Tage ✓ (war bereits korrekt)
- Stufe 2 → Purge (Hidden → gelöscht): **weitere 30 Tage** → Code-Default war 90 Tage ❌
**Technische Änderung:**
```python
# media_lifecycle.py Zeile 24 (vorher "90", jetzt "30"):
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30")))
```
`docker-compose.yml` enthält jetzt explizit `MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS: "${...:-30}"` mit Kommentar, der das 30+30-Konzept dokumentiert. Der Wert kann per Env-Variable in einzelnen Deployments überschrieben werden.
---
### P-04 Copyright-Pflicht bei Archiv-Promotion vereinheitlichen ✅
**Status:** Umgesetzt; Tests nachgehärtet (2026-05-10)
**Betroffene Dateien:**
- `backend/routers/media_assets.py` `patch_media_asset()` und `bulk_media_patch()`
- `backend/tests/test_media_assets_copyright_promotion.py`
**Technische Änderung:**
Beide Endpoints prüfen, ob `copyright_notice` vorhanden ist, wenn `visibility` auf `club` oder `official` gewechselt wird. Priorität: Wert aus dem Request-Body > bestehender Wert in der DB. Ist beides leer, wird HTTP 400 zurückgegeben.
Fehlermeldung: `"Für Vereins- oder offizielle Medien ist eine Urheberrechtsangabe (copyright_notice) Pflicht."` (Umlaut korrekt, 2026-05-10 korrigiert)
**Tests:** 7 Tests, alle grün:
- Promotion zu `club` ohne Copyright → **400** (exakt)
- Promotion zu `official` ohne Copyright → **400** (exakt)
- Promotion zu `club` mit Copyright im Body → **200** + Payload-Prüfung (gehärtet)
- Promotion zu `club`, Copyright bereits auf Asset → **200** + Payload-Prüfung (gehärtet)
- Kein Visibility-Wechsel → keine Copyright-Prüfung → **200** (exakt)
- Bulk: ohne Copyright → in `failed`, `updated_count == 0` (exakt)
- Bulk: mit Copyright → in `updated`, `updated_count == 1` (exakt)
---
### P-05 Passwort-Mindestlänge angleichen ⚠️ (Teil 1 von 2 Re-Audit-Auflage offen)
**Status:** Teilweise umgesetzt
**Betroffene Dateien (initialer Fix 2026-05-09):**
- `backend/routers/auth.py:101` `PUT /api/auth/pin`: `< 4``< 8`
- `frontend/src/pages/LoginPage.jsx:175` `minLength="6"``minLength="8"`
- `frontend/src/pages/AccountSettingsPage.jsx:403` `minLength={4}``minLength={8}`
**Verbleibende Lücke identifiziert im Re-Audit 2026-05-09:** `POST /api/auth/reset-password` hatte kein Mindestlängen-Limit → siehe P-05b.
---
### P-05b reset-password Mindestlänge 8 Zeichen ✅
**Status:** Umgesetzt (2026-05-10)
**Betroffene Dateien:**
- `backend/models.py:29` `PasswordResetConfirm.new_password`
- `backend/tests/test_auth_password_reset_minlength.py` 7 neue Tests
**Technische Änderung:**
```python
# models.py (vorher):
class PasswordResetConfirm(BaseModel):
token: str
new_password: str
# models.py (nachher):
class PasswordResetConfirm(BaseModel):
token: str
new_password: str = Field(min_length=8, max_length=128)
```
FastAPI lehnt Requests mit `new_password < 8 Zeichen` nun mit HTTP **422** (Pydantic Validation Error) ab, bevor der Endpoint-Handler ausgeführt wird. Kein DB-Zugriff erfolgt für unvalide Requests.
**Tests:** 7 Tests, alle grün:
- Leer-String → 422
- 1-Zeichen-Passwort → 422
- 7-Zeichen-Passwort (`"1234567"`) → 422
- Exakt 8 Zeichen (`"12345678"`) → **200**
- Langes Passwort → **200**
- Fehlendes `new_password`-Feld → 422
- Gültiges Passwort, ungültiger Token → 400
**P-05 vollständig geschlossen?** Ja — alle passwortverarbeitenden Backend-Endpoints erzwingen jetzt Mindestlänge 8:
| Endpoint | Mindestlänge | Mechanismus |
|---|---|---|
| `POST /api/auth/register` | 8 | `if len(password) < 8` im Handler |
| `PUT /api/auth/pin` | 8 | `if len(new_pin) < 8` im Handler |
| `POST /api/auth/reset-password` | 8 | Pydantic `Field(min_length=8)` |
| Management-Reset (profiles.py) | 8 | Pydantic `Field(min_length=8)` |
| Frontend LoginPage | 8 | `minLength="8"` |
| Frontend AccountSettingsPage | 8 | `minLength={8}` |
---
### P-07 ALLOW_PUBLIC_MEDIA_STATIC Release-Test ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/tests/test_security_release.py` 2 neue Tests
**Tests:** Beide grün.
---
### P-11 Legal-Hold Lifecycle-Status ✅
**Status:** Vollständig umgesetzt (2026-05-11, Version 0.8.84 + Nachfixe 0.8.850.8.86)
#### P-11 Kernum­setzung (Version 0.8.84)
**Betroffene Dateien:**
- `backend/migrations/051_legal_hold.sql` neue Spalten `legal_hold_active`, `legal_hold_reason_code`, `legal_hold_reason_note`, `legal_hold_set_by_profile_id`, `legal_hold_set_at`, `legal_hold_released_by_profile_id`, `legal_hold_released_at`, `legal_hold_release_note` in `media_assets`; Audit-Log-CHECK um `legal_hold_set`/`legal_hold_released` erweitert
- `backend/media_legal_hold.py` Neu: zentrale Services `set_legal_hold`, `release_legal_hold`, `assert_not_under_legal_hold`, `is_media_available_for_normal_use`, `assert_superadmin_for_legal_hold`
- `backend/media_lifecycle.py` `run_retention_pass()` filtert `AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)` in beiden Retention-Queries
- `backend/routers/media_assets.py` `admin_legal_hold_router` mit drei Endpoints; `_list_active_visibility_clause()` berücksichtigt `include_legal_hold`-Parameter; `download_media_asset_file()` prüft Legal-Hold für Nicht-Superadmins
- `backend/routers/exercises.py` `attach_exercise_media_from_asset()` ruft `assert_not_under_legal_hold()` vor Verknüpfung auf
- `backend/main.py` `app.include_router(media_assets.admin_legal_hold_router)`
- `frontend/src/utils/api.js` `setMediaAssetLegalHold`, `releaseMediaAssetLegalHold`, `listMediaAssetsWithLegalHold`
- `frontend/src/pages/MediaLibraryPage.jsx` Legal-Hold-Badge auf Kacheln; Superadmin-Aktionen „Sofort sperren"/„Sperre aufheben" im Edit-Modal; Bestätigungs-Dialog mit Pflichtfeldern; Journal-Renderpfad für `legal_hold_set`/`legal_hold_released`
- `frontend/src/app.css` CSS-Klassen für Legal-Hold-Badge, -Dialog, -Button
**Neue API-Endpoints (Superadmin):**
- `POST /api/admin/media-assets/{asset_id}/legal-hold` — Sofortsperre setzen (reason_code + reason_note Pflicht)
- `POST /api/admin/media-assets/{asset_id}/legal-hold/release` — Sofortsperre aufheben (release_note Pflicht)
- `GET /api/admin/media-assets/legal-hold` — Liste aller aktuell gesperrten Assets
**Tests:** 15 Backend-Unit-Tests in `backend/tests/test_p11_legal_hold.py` — alle grün.
#### P-11 Nachfixe UI-Bugs und Sichtbarkeitskorrektur (Version 0.8.850.8.86)
Nach Erstimplementierung wurden beim Testen weitere Mängel festgestellt und behoben:
**Version 0.8.85 UI-Bugfixes:**
- Fix: `submitLegalHold` rief `loadMedia()` statt `loadItems()` auf → "loadMedia is not a function" beim Sperren
- Fix: Listabfrage in `list_media_assets` enthielt `legal_hold_active`, `reason_code`, `reason_note`, `set_at` nicht → Badge und „Sperre aufheben"-Button im Modal waren nie sichtbar
- Fix: Journal-Renderpfad verwendete Keys `nw.legal_hold_reason_code/note` statt `nw.reason_code/note` (Audit-Log-Format) → Begründung und Kommentar nicht angezeigt
- Fix: Archiv-Picker (ExerciseFormPage, ExerciseInlineFileMediaModal) filterte Legal-Hold-Assets bereits client-seitig heraus
**Version 0.8.86 Vollständige Absicherung der Auslieferung:**
Betroffene Dateien:
- `backend/routers/exercises.py` `download_exercise_media_file()` gibt HTTP 451 zurück wenn `asset_legal_hold_active=TRUE` (Datei wird nicht ausgeliefert); `enrich_exercise_detail()` SELECT erweitert um `ma.legal_hold_active AS asset_legal_hold_active`
- `backend/routers/media_assets.py` `list_media_assets` übergibt `include_legal_hold=is_sup` statt `include_legal_hold=(is_plat or is_sup)` — Legal-Hold-Assets nur noch für Superadmin sichtbar, nicht für alle Plattform-Admins
- `frontend/src/components/ExerciseMediaEmbed.jsx` Zeigt „Medium nicht verfügbar (gesperrt)" statt Datei wenn `asset_legal_hold_active`
- `frontend/src/components/ExerciseMediaThumbTile.jsx` Zeigt rot-markierte „Gesperrt"-Kachel statt Dateivorschau; kein Preview-Trigger
- `frontend/src/pages/ExerciseFormPage.jsx` Vorschau-Modal zeigt Hinweis statt Datei wenn `asset_legal_hold_active`
#### Sicherheitsarchitektur (vollständig)
- Legal-Hold ist orthogonal zum normalen Papierkorb-Lifecycle (P-03) — kein 30-Tage-Warten
- `rights_status='blocked'` wird als Schnell-Spiegel gesetzt und bei Aufhebung basierend auf vorhandenen Deklarationen wiederhergestellt (`declared` wenn Deklaration vorhanden, sonst `legacy_unreviewed`)
- Nur Superadmin darf setzen/aufheben; nur Superadmin sieht Legal-Hold-Assets in der Medienliste; normale Nutzer sehen gesperrte Assets nicht
- Retention-Job überspringt Legal-Hold-Assets (verhindert versehentliche Löschung unter laufender Sperrmaßnahme)
- `assert_not_under_legal_hold()` blockiert das Verknüpfen von Legal-Hold-Assets mit Übungen
- Dateiauslieferung (`download_exercise_media_file`) gibt HTTP 451 zurück — keine Umgehung via direkten File-Endpoint
- Frontend-Komponenten zeigen Placeholder statt Datei, auch wenn das Asset bereits in einer Übung verknüpft ist
---
### P-12 sessionStorage bei Logout bereinigen ✅
**Status:** Umgesetzt (2026-05-10, Version 0.8.68)
**Betroffene Dateien:**
- `frontend/src/context/AuthContext.jsx` `logout()`
- `tests/dev-smoke-test.spec.js` neuer Playwright-Test
**Befund (war offen):**
`logout()` löschte nur `localStorage`-Einträge. `TrainingCoachPage` schrieb sessionStorage-Schlüssel mit Präfix `sj_coach_` (`sj_coach_step_{unitId}`, `sj_coach_deltas_{unitId}`, `sj_coach_debrief_{unitId}`), die nach Logout im Tab erhalten blieben. Bei Nutzerwechsel im selben Tab (geteilter Rechner) konnte der neue Nutzer Trainingsfortschritt des Vorgängers sehen.
**Technische Änderung:**
Gezielte Präfix-Löschung aller `sj_coach_*`-Schlüssel beim Logout (kein `sessionStorage.clear()`). Fremde sessionStorage-Schlüssel (Browser-Extensions o. ä.) bleiben erhalten.
```javascript
// AuthContext.jsx logout() — Ergänzung:
for (const key of Object.keys(sessionStorage)) {
if (key.startsWith('sj_coach_')) {
sessionStorage.removeItem(key)
}
}
```
**Begründung gezielte statt vollständiger Löschung:**
Alle Shinkan-spezifischen sessionStorage-Schlüssel sind eindeutig über den Präfix `sj_coach_` identifizierbar (definiert in `TrainingCoachPage.jsx` Zeilen 1525). Ein `sessionStorage.clear()` würde auch Schlüssel fremder Quellen im selben Tab löschen; die Präfix-Löschung ist spezifischer und sicherer.
**Tests:** Playwright E2E-Test `tests/dev-smoke-test.spec.js` (Test „P-12: sessionStorage wird bei Logout bereinigt"):
- Setzt drei `sj_coach_*`-Schlüssel und einen fremden Schlüssel
- Klickt „Abmelden"
- Prüft: `sj_coach_*` → null (entfernt), fremder Schlüssel → erhalten, `authToken` → null (localStorage weiterhin korrekt bereinigt)
**Hinweis Tests:** Das Projekt verfügt über kein Frontend-Unit-Test-Framework (kein Vitest/Jest in package.json). Der Test ist als Playwright E2E-Test implementiert, der einen laufenden Dev-Server voraussetzt. Automatisierte Ausführung der Playwright-Tests erfordert `npx playwright test` mit gesetzter `PLAYWRIGHT_BASE_URL`.
---
### P-23 LoginPage: minLength + Versionsstring ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `frontend/src/pages/LoginPage.jsx`
**Technische Änderung:**
- `minLength="6"``minLength="8"`
- Hardcodierter Versionsstring `v0.1.0 • Development` entfernt
---
### P-24 CORS allow_methods und allow_headers einschränken ✅
**Status:** Umgesetzt
**Betroffene Dateien:**
- `backend/main.py:85-86`
**Technische Änderung:**
```python
allow_methods=["GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"],
allow_headers=["Content-Type", "X-Auth-Token", "X-Active-Club-Id"],
```
---
## Test-Zusammenfassung (Stand 0.8.94)
```
tests/test_auth_password_reset_minlength.py 7 passed (neu, P-05b)
tests/test_media_assets_copyright_promotion.py 7 passed (gehärtet, P-04)
tests/test_media_rights_declaration.py 25 passed (neu, P-06aP-06d)
tests/test_security_release.py 9 passed (inkl. 2 P-07-Tests)
tests/test_p11_legal_hold.py 15 passed (neu, P-11)
tests/test_p13_content_reports.py 15 passed (neu, P-13; CI-Fix 0.8.94)
Weitere bestehende Tests: 81 passed, 6 skipped
Gesamt (Backend): 159 passed, 6 skipped, 1 failed
Fehlgeschlagener Test: test_list_media_assets_invalid_lifecycle_400
→ Pre-existing: benötigt laufenden PostgreSQL-Container (Hostname "postgres")
→ Bestand bereits vor allen Compliance-Änderungen (verifiziert per git stash)
Playwright E2E (dev-smoke-test.spec.js): 22+ passed (inkl. P-06-Tests)
→ P-01: 4× Route ohne Auth + Platzhalterhinweis + Reload, 1× Login-Links
→ P-01b: 3× /settings/legal (Link in Einstellungen, Überschrift, Rechtstext-Links)
→ P-01c: 3× Admin-Rechtstexte (Seitenladung, Route /admin/legal-documents, Admin-Nav)
→ P-12: sessionStorage-Bereinigung (grün)
→ P-06: 5× RightsDeclarationDialog (Anzeige, Pflichtfeld-Validierung, Upload-Flow,
Promotions-Dialog, Legacy-Altbestand-Indikator)
Anmerkung jsPDF (0.8.74):
→ PDF-Download via pdf.save() ist ein Browser-Download, kein Server-Request.
→ Kein Backend-Test möglich; funktional im Browser verifizierbar.
→ Playwright-E2E-Test erfordert laufenden Dev-Server (npx playwright test).
Anmerkung P-06+ Journal/Korrektur (0.8.820.8.83):
→ Journal-Endpoint und Korrektur-Endpoint durch manuellen API-Test (curl) auf Dev-System verifiziert.
→ Bugfix club_admin-Prüfung (has_club_role) verifiziert: 500 → 200 nach Fix.
→ Keine dedizierte Playwright-Testsuite für Journal-Modal und Korrektur-Formular (UI-Verifikation ausstehend).
Anmerkung P-11 Frontend-Absicherung (0.8.850.8.86):
→ UI-Fixes (loadItems, Badge-Sichtbarkeit, Journal-Keys) manuell auf Dev-System verifiziert.
→ 15 Backend-Unit-Tests decken Services und Retention-Schutz ab.
→ Keine Playwright-Tests für Legal-Hold-Aktionen im Modal — manuelle UI-Verifikation.
Anmerkung P-13 Erweiterungen (0.8.880.8.94):
→ E-Mail-Benachrichtigungen, Audit-Log, Club-Admin-Rechte, Workflow manuell auf Dev-System verifiziert.
→ CI-Fix (0.8.94): 3 pytest-Tests angepasst (frühzeitige 403, vollständige Mock-Zeile, DB-Mock für COUNT).
→ Alle 15 pytest-Tests grün nach CI-Fix.
→ Keine Playwright-Tests für InboxPage Meldungs-Workflow — manuelle UI-Verifikation.
```
---
### P-06 Upload-Einwilligungsdialog ⚠️ (technisch vollständig umgesetzt inkl. P-06+; juristische Validierung offen)
**Status:** Technisch vollständig umgesetzt (inkl. P-06+ Volljournal + Korrektur, Version 0.8.83) — KRIT-04 bleibt offen.
**Deklarationsversion:** `p06-v1-conservative`
---
#### P-06aP-06d — Kernum­setzung (Version 0.8.75)
**Betroffene Dateien:**
- `backend/migrations/048_media_rights_declarations.sql` (neu) — Append-only Deklarations-Log + 3 Schnellfelder in `media_assets` (`rights_status`, `rights_declared_for_visibility`, `rights_declared_at`)
- `backend/migrations/049_media_rights_consent_context.sql` (neu) — Kontext-Freitextfelder in `media_asset_rights_declarations` (`person_consent_context`, `parental_consent_context`, `music_rights_context`, `third_party_rights_context`)
- `backend/media_rights.py` (neu) — Zentrales Policy-Modul: `validate_rights_declaration`, `check_rights_coverage`, `assert_rights_for_promotion`, `assert_rights_for_exercise_link`, `write_rights_declaration`, `update_rights_quick_fields`
- `backend/routers/media_assets.py` — P-06-Enforcement in Bulk-Upload, PATCH, Bulk-PATCH; 3 neue Endpoints
- `backend/routers/exercises.py` — P-06 bei `upload_exercise_media` (neue Assets) und `attach_exercise_media_from_asset`
- `frontend/src/components/RightsDeclarationDialog.jsx` (neu) — Einwilligungsdialog (9 Pflichtfelder + Kontext-Freitexte)
- `frontend/src/pages/MediaLibraryPage.jsx` — Dialog-Integration vor Bulk-Upload; Altbestand-Indikator
- `frontend/src/pages/ExerciseInlineFileMediaModal.jsx` + `ExerciseInlineEmbedModal.jsx` — RightsDeclarationDialog vor Upload
- `frontend/src/utils/api.js``bulkUploadMediaAssets` erweitert um P-06-Felder
- `backend/tests/test_media_rights_declaration.py` (neu) — 25 Unit/HTTP-Tests
**Neue Endpoints (P-06b):**
| Endpoint | Beschreibung |
|----------|-------------|
| `POST /api/media-assets/{id}/rights-declarations` | Explizite Re-/Nachdeklaration |
| `GET /api/admin/media-rights/legacy-summary` | Zusammenfassung Altbestand nach Sichtbarkeit (Plattform-Admin) |
| `GET /api/admin/media-rights/legacy-assets` | Paginierte Liste Altbestand club/official (Plattform-Admin) |
**Abweichung von Spec §3 (konservative Erstannahme):**
Person-Fragen sind auch bei Sichtbarkeit `private` Pflicht (§10.1 in `docs/p06-upload-rights-spec.md`).
**Altbestand (Legacy):**
Alle vor Migration 048 hochgeladenen Medien erhalten `rights_status = 'legacy_unreviewed'`.
Promotion blockiert bis Nachdeklaration. In Bibliotheks-UI als „Altbestand ⚠" markiert.
---
#### P-06+ — Volljournal + Korrektur (Version 0.8.820.8.83)
**Motivation:** Vollständige Nachvollziehbarkeit aller Medien-Ereignisse, nicht nur der Deklarationen. Plus: Möglichkeit, fehlerhafte Deklarationen mit Begründung nachträglich zu korrigieren (append-only — neueste gilt).
**Betroffene Dateien:**
- `backend/migrations/050_media_audit_log.sql` (neu) — Neue Tabelle `media_asset_audit_log` + `correction_note TEXT` in Deklarations-Tabelle + erweiterter CHECK (action_type += 'correction')
- `backend/media_rights.py` — Neue Funktionen: `write_audit_log_entry()`, `write_rights_correction_declaration()`
- `backend/routers/media_assets.py` — Neues Pydantic-Model `RightsCorrectionBody`; PATCH-Endpoint schreibt Audit-Log-Einträge; Lifecycle-Aktionen schreiben `lifecycle_change`-Einträge; 2 neue Endpoints; Import `has_club_role` ergänzt
- `frontend/src/pages/MediaLibraryPage.jsx` — Journal-Modal komplett überarbeitet: unified `events[]`-Ansicht; Korrektur-Formular inline; Helper-Funktionen `actionTypeLabel`, `eventTypeLabel`, `visLabel`
- `frontend/src/utils/api.js` — Neue Funktion `addMediaAssetDeclarationCorrection(assetId, body)`
- `frontend/src/app.css` — CSS für Audit-Einträge (`--audit`), Korrektur-Einträge (`--correction`), Korrektur-Formular
**Neue Endpoints (P-06+):**
| Endpoint | Auth | Beschreibung |
|----------|------|-------------|
| `GET /api/admin/media-rights/assets/{id}/journal` | Superadmin / Uploader / Vereins-Admin | Volljournal: `events[]` aus Deklarationen + Audit chronologisch gemischt. Gibt `can_correct` zurück. |
| `POST /api/admin/media-rights/assets/{id}/correction` | Superadmin / Uploader / Vereins-Admin | Korrektur-Deklaration (append-only, neueste gilt). Felder = P-06-Dialog + `correction_note`. |
**Automatische Audit-Log-Einträge:**
| event_type | Auslöser |
|-----------|----------|
| `visibility_change` | PATCH wenn `visibility` oder `club_id` sich ändert |
| `copyright_change` | PATCH wenn `copyright_notice` sich ändert |
| `metadata_change` | PATCH wenn `original_filename` etc. sich ändert |
| `lifecycle_change` | Lifecycle-Aktionen: trash_soft, trash_hidden, recover, reactivate (nicht bei Hard-Delete/Purge) |
**Bugfixes in P-06+:**
| Bug | Fix |
|-----|-----|
| Journal + Korrektur gaben 500 (falsches Schema club_admin) | `has_club_role(cur, profile_id, club_id, "club_admin")` statt `AND role = 'admin'` in `club_members` |
| Frontend-Build-Fehler: doppelte `lcLabel`-Deklaration | Duplikat in Zeile 264 von `MediaLibraryPage.jsx` entfernt |
**KRIT-04 Status:**
Offen. Juristische Validierung der Feldtexte (§10.3 p06-v1-conservative, T1T10), KUG/DSGVO-Anforderungen (§7.1§7.12 der Spec) und Korrekturfähigkeit (Spec §11.9) steht aus.
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.870.8.94)
**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.
#### P-13 Kernum­setzung (Version 0.8.87)
**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, Reason-Code-Mapping, Priorisierung
- `backend/tests/test_p13_content_reports.py` (neu) — 15 Unit-Tests
- `backend/main.py` — Router-Registrierung
- `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
- `frontend/src/components/ReportContentModal.jsx` (neu) — Meldungsformular
- `frontend/src/components/MediaPreviewModal.jsx` (neu) — geteilter Vorschau-Dialog
**API-Endpoints (Kernum­setzung):**
| 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 |
---
#### P-13 Erweiterungen — Audit-Log, E-Mail, Workflow, Club-Admin (Version 0.8.880.8.94)
**Betroffene Dateien:**
- `backend/migrations/053_content_report_audit_event.sql` (neu) — `content_report_filed` Event-Typ dem `media_asset_audit_log` CHECK-Constraint hinzugefügt (alle 7 gültigen Event-Typen)
- `backend/routers/content_reports.py` — stark erweitert: E-Mail-Benachrichtigungen, Audit-Log, deutsche Labels, Club-Admin-Berechtigungen, Workflow-Verbesserungen, frühzeitige 403-Prüfung
- `backend/routers/media_assets.py``open_report_count` Batch-Query für Admin-Nutzer in `list_media_assets`
- `frontend/src/pages/InboxPage.jsx` — vollständig überarbeitetes `ReportDetailModal` mit Workflow-Bar, Archiv-Trennung
- `frontend/src/pages/MediaLibraryPage.jsx` — Badge auf Medienkarten, Journal-Eintrag für `content_report_filed`
- `frontend/src/components/ReportContentModal.jsx``onSuccess` Callback, readOnly-Felder für eingeloggte Nutzer
- `frontend/src/context/OrgInboxContext.jsx``contentReportsError`, `isClubAdmin`, `isPlatformAdmin` exponiert
**Backend-Erweiterungen:**
- **E-Mail-Benachrichtigungen** (best-effort, nach Commit): Bestätigung an Melder + Benachrichtigung aller Plattform-Admins nach Eingang einer Meldung; `original_filename` oder `exercises.title` als Medienbezeichnung im E-Mail-Betreff
- **Audit-Log-Integration**: Bei `target_type='media_asset'` Eintrag `content_report_filed` in `media_asset_audit_log`; bei Statuswechseln und reinen Notizänderungen via PATCH ebenfalls; deutsche Labels via `REASON_LABELS_DE` / `STATUS_LABELS_DE`
- **Club-Admin-Berechtigungen**: `GET /me/inbox/content-reports` liefert Club-Admins nur Meldungen zu Medien ihrer Vereine (COUNT-Check, 403 wenn keine Club-Admin-Rolle); `GET /content-reports/{id}` und `PATCH` via `_assert_can_manage_report()` auch für Club-Admins (nur eigene Vereinsmedien); `POST /content-reports/{id}/legal-hold` für Club-Admins auf nicht-offizielle Vereinsmedien via `_assert_can_set_legal_hold_from_report()`; Superadmin-Prüfung frühzeitig vor DB-Zugriff (plain Admin → sofort 403)
- **Workflow-Verbesserungen in PATCH**: Wiedereröffnen einer Meldung (`status='submitted'`) setzt `reviewed_by_profile_id` und `reviewed_at` auf NULL zurück
- **`open_report_count`** in `list_media_assets`: Für Admin-Nutzer wird je Medium die Anzahl offener Meldungen (`submitted`/`under_review`) als Batch-Query zurückgegeben
**Frontend-Erweiterungen:**
- **`WorkflowBar`** in InboxPage: 3-Schritte-Fortschrittsbalken (Eingegangen → In Bearbeitung → Abgeschlossen) mit visueller Hervorhebung des aktuellen Schritts
- **`ReportDetailModal`** überarbeitet: zeigt WorkflowBar, Prüferinformationen (`reviewed_by_name`, `reviewed_at`), `resolution_note` (readOnly bei abgeschlossenen Meldungen), eigener „Kommentar speichern"-Button, „Meldung wieder öffnen"-Button für abgeschlossene Meldungen
- **Archiv-Trennung**: Offene Meldungen (`submitted`/`under_review`) im Hauptbereich; abgeschlossene/abgewiesene Meldungen in einer kollabierbaren Archiv-Sektion (Standard: zugeklappt)
- **Legal-Hold-Button** in ReportDetailModal: sichtbar für Superadmin sowie Club-Admins (bei nicht-offiziellen Medien)
- **Badge auf Medienkarten** in MediaLibraryPage: rotes Badge mit `open_report_count` (nur sichtbar wenn > 0); wird nach erfolgreicher Meldung via `onSuccess`-Callback sofort aktualisiert
- **Journal-Eintrag** in MediaLibraryPage: `content_report_filed`-Ereignisse mit Meldungs-ID, Grund, Priorität, Status, Begründung
- **`OrgInboxContext`**: `contentReportsError` (Fehlerstring oder null), `isClubAdmin` und `isPlatformAdmin` exponiert; `contentReportCount` zählt nur offene Meldungen (submitted/under_review)
- **`ReportContentModal`**: `onSuccess`-Callback nach erfolgreichem Submit; Name und E-Mail readOnly für eingeloggte Nutzer (grauer Hintergrund)
- **Melde-Einstieg**: `MediaPreviewModal` (geteilt) + Melden-Button in MediaLibraryPage, ExerciseFormPage und ExerciseAttachmentMediaStrip
**CI-Fix (Version 0.8.94):**
3 pytest-Tests in `test_p13_content_reports.py` repariert: frühzeitige 403-Prüfung in `set_legal_hold_from_report` für plain Admin (vor DB-Zugriff); Test 10 mit korrektem DB-Mock für COUNT-Query; Test 12 mit vollständiger Mock-Zeile (`target_type`, `target_id`, `resolution_note`). Alle 15 Tests grün.
**Nicht in P-13-Scope:** P-14 (Moderations-UI als eigene Seite), P-15 (Uploader-Benachrichtigung per E-Mail bei Sperrung), P-16 (Beschwerdeverfahren).
---
## Nicht umgesetzte Pakete
> Paket-IDs und -Titel gemäß kanonischem Register `docs/compliance-package-register.md`.
> Abweichende Beschreibungen in der Ursprungsversion dieses Abschnitts wurden am 2026-05-10 korrigiert (P-06, P-08, P-09, P-10, P-11, P-18 — Details im Konsistenzbericht des Registers).
| Paket | Kanonischer Titel | Status | Begründung |
|-------|------------------|--------|------------|
| P-01 | Rechtstexte | offen | Scope ausgeschlossen (juristischer Inhalt) |
| P-02 | Self-Service-Kontolöschung + Datenexport | offen | Scope ausgeschlossen |
| P-06 | Upload-Einwilligungsdialog (Recht am eigenen Bild) | **teilweise umgesetzt** | Technisch umgesetzt (2026-05-11, v0.8.75) unter vorläufigen Erstannahmen `p06-v1-conservative` — siehe §P-06 unten. KRIT-04 bleibt offen bis juristische Validierung. |
| P-08 | HSTS / externe Proxy-Sicherheit dokumentieren | offen | Scope ausgeschlossen (außerhalb Repo — Reverse-Proxy) |
| P-09 | Admin-Audit-Log | offen | Scope ausgeschlossen |
| P-10 | Mindestalter-Abfrage | offen | Scope ausgeschlossen |
| P-11 | Legal-Hold Lifecycle-Status | ✅ umgesetzt | Version 0.8.840.8.86 — siehe §P-11 oben |
| P-12 | sessionStorage bei Logout bereinigen | ✅ umgesetzt | Version 0.8.68 — siehe §P-12 oben |
| P-13 | Content-Melde-Backend | ✅ umgesetzt | Version 0.8.87 — siehe §P-13 unten |
| P-14 | Moderations-UI | offen | Scope ausgeschlossen |
| P-15 | Uploader-Benachrichtigung bei Sperrung | offen | Scope ausgeschlossen |
| P-16 | Beschwerdeverfahren | offen | Scope ausgeschlossen |
| P-17 | MFA für Superadmins (TOTP) | offen | Scope ausgeschlossen |
| P-18 | HttpOnly-Cookie als Auth-Alternative | offen | Scope ausgeschlossen |
| P-19 | Anti-Virus-Scan (ClamAV) | offen | Scope ausgeschlossen |
| P-20 | VVT erstellen | offen | Scope ausgeschlossen (Betreiber-Aufgabe) |
| P-21 | AV-Verträge abschließen | offen | Scope ausgeschlossen (Betreiber-Aufgabe) |
| P-22 | HTML-Sanitizer für Rich-Text-Felder | offen | Scope ausgeschlossen |
---
## Re-Audit-Empfehlung
Operativ prüfen (Stand 0.8.94):
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
3. **P-05b**: Manuell: Reset-Link mit 7-Zeichen-Passwort → muss mit Fehler abgewiesen werden
4. **P-24**: Browser DevTools Preflight → `Access-Control-Allow-Headers: content-type, x-auth-token, x-active-club-id`
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
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 Grundfunktion**: Anonym → `POST /api/content-reports` für official-Medium ohne Auth → muss 200 liefern; `good_faith_confirmed=false` → 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`
9. **P-13 E-Mail**: Meldung einreichen → Melder erhält Bestätigungs-E-Mail; Plattform-Admins erhalten Benachrichtigungs-E-Mail (sofern SMTP konfiguriert)
10. **P-13 Club-Admin**: Club-Admin → `GET /api/me/inbox/content-reports` → liefert nur Meldungen zu Medien des eigenen Vereins; Club-Admin → `PATCH /api/content-reports/{id}` für Meldung zu eigenem Vereinsmedium → muss 200 liefern
11. **P-13 Audit-Log**: Meldung einreichen für media_asset → `media_asset_audit_log` muss Eintrag `content_report_filed` enthalten; Statuswechsel via PATCH → muss weiteren Audit-Eintrag enthalten
12. **P-13 Badge**: Medium mit offener Meldung in Medienbibliothek → rotes Badge mit Zähler sichtbar
13. **P-13 Workflow**: Abgeschlossene Meldungen in InboxPage im Archiv (kollabierbar); Wiedereröffnen einer Meldung → `reviewed_by_profile_id` und `reviewed_at` werden zurückgesetzt
Nächster vollständiger Re-Audit empfohlen nach juristischer Klärung P-06/KRIT-04 (Textfreigabe T1T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.

View File

@ -0,0 +1,435 @@
# Compliance-Paketregister Shinkan Jinkendo
**Typ:** Kanonisches Referenzdokument
**Erstellt:** 2026-05-10
**Basisdokument:** `docs/compliance-audit.md` (Initial-Audit 2026-05-09, App-Version 0.8.65)
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.94)
---
> **Verbindliche Regel:** Paket-IDs werden nach ihrer ersten Vergabe in `docs/compliance-audit.md` nie wieder umnummeriert oder wiederverwendet. Dieses Dokument ist der kanonische Verweis für alle Freigaben, Umsetzungsberichte und Audits. Abweichende Nummerierungen in nachgelagerten Dokumenten sind Fehler und müssen auf die kanonische ID korrigiert werden. Nacharbeiten zu einem Paket erhalten Suffixe (z. B. P-03b, P-05b), keine neue Hauptnummer.
---
## Status-Legende
| Symbol | Bedeutung |
|--------|-----------|
| ✅ implemented | Vollständig umgesetzt und getestet |
| ⚠️ partially implemented | Teilweise umgesetzt; Nacharbeit dokumentiert |
| ❌ open | Nicht umgesetzt, offen |
| 🔒 scope excluded | Bewusst außerhalb des technischen Umsetzungsscopes |
---
## Etappe 1 Pflicht vor öffentlichem Betrieb
### P-01 Rechtstexte
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Rechtstexte (Impressum, Datenschutzerklärung, AGB, Medienrichtlinie) |
| **Findings** | KRIT-01 |
| **Etappe** | 1 |
| **Status** | ⚠️ partially implemented |
| **Letzter Stand** | Technischer Teil vollständig umgesetzt (2026-05-10, Version 0.8.74, inkl. P-01b + P-01c + Erweiterungen): Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`, `/medienrichtlinie` öffentlich erreichbar ohne Auth. Platzhalterseiten mit strukturierten Pflichtfeldern und sichtbarem Muster-Hinweis. Links in LoginPage, DesktopSidebar und `/settings/legal` (Mobile/PWA, P-01b, 0.8.70). Admin-konfigurierbare Rechtstexte mit Versionierung + Publish/Archive-Workflow (DB 047, Superadmin-UI `/admin/legal-documents`, P-01c, 0.8.71). Als-Entwurf-kopieren: `POST /api/admin/legal-documents/{id}/copy-as-draft` (0.8.72). Echter PDF-Download via jsPDF (direkter Dateidownload, A4, Seitenumbruch, Footer), Abschnitte sortieren (▲/▼) und an beliebiger Stelle einfügen (0.8.74). **Juristisch geprüfte Inhalte fehlen noch — Betreiber + Rechtsanwalt erforderlich. KRIT-01 bleibt offen bis zur Veröffentlichung echter Texte.** |
| **Verweise** | `docs/compliance-audit.md` §14.2, §17, §19.1; `docs/compliance-implementation.md` §P-01, §P-01b, §P-01c; `frontend/src/pages/LegalPage.jsx`; `frontend/src/pages/SettingsLegalPage.jsx`; `frontend/src/pages/AdminLegalDocumentsPage.jsx`; `backend/routers/legal_documents.py`; `backend/migrations/047_legal_documents.sql` |
| **Hinweise** | P-01b (Mobile-Erreichbarkeit, 0.8.70) und P-01c (Admin-konfigurierbar, 0.8.71) als Suffix-Pakete ergänzt und vollständig umgesetzt. Erweiterungen: copy-as-draft (0.8.72), jsPDF + Abschnitts-Sortierung/-Einfügen (0.8.74). Keine Nummerierungsabweichung. |
---
### P-02 Self-Service-Kontolöschung + Datenexport
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Self-Service-Kontolöschung und Datenexport (DSGVO Art. 17, 15, 20) |
| **Findings** | KRIT-02, KRIT-05 |
| **Etappe** | 1 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. `DELETE /api/profiles/{pid}` nur für Plattform-Admin. |
| **Verweise** | `docs/compliance-audit.md` §7.3, §11.2, §17 |
| **Hinweise** | In `docs/compliance-implementation.md` als „Self-Service-Löschworkflow" bezeichnet — inhaltlich korrekt, Titel leicht abgekürzt. |
---
### P-03 Papierkorb-Retention-Job aktivieren
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Papierkorb-Retention-Job aktivieren (automatische tägliche Ausführung) |
| **Findings** | KRIT-07 |
| **Etappe** | 1 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt in Version 0.8.66. Neuer Docker-Service `retention-cron` in `docker-compose.yml`. Python-basierter Loop, täglich 03:00 Uhr. |
| **Nacharbeit** | P-03b (Retention-Zeiten mit Löschkonzept abgleichen) — siehe unten |
| **Verweise** | `docs/compliance-audit.md` §9.2, §17; `docs/compliance-implementation.md` §P-03 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
#### P-03b Nacharbeit: Retention-Zeiten mit Löschkonzept abgleichen
| Feld | Inhalt |
|------|--------|
| **Typ** | Nacharbeit zu P-03 (kein eigenes Hauptpaket) |
| **Status** | ✅ implemented (2026-05-10, Version 0.8.67) |
| **Befund** | Default `HIDDEN_TO_PURGE_DAYS` war 90 Tage; fachliches Löschkonzept sieht 30+30 vor. |
| **Änderung** | `backend/media_lifecycle.py`: Default `"90"``"30"`; `docker-compose.yml`: Env-Variable dokumentiert. |
| **Verweise** | `docs/compliance-implementation.md` §P-03b |
---
### P-04 Copyright-Pflicht für Archiv-Promotion vereinheitlichen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Copyright-Pflicht bei Archiv-Promotion vereinheitlichen |
| **Findings** | KRIT-06 |
| **Etappe** | 1 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt in Version 0.8.66. `patch_media_asset()` und `bulk_media_patch()` erzwingen `copyright_notice` bei Promotion auf `club`/`official`. 7 Tests, alle grün. |
| **Verweise** | `docs/compliance-audit.md` §6.3, §8.1, §17; `docs/compliance-implementation.md` §P-04; `backend/tests/test_media_assets_copyright_promotion.py` |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-05 Passwort-Mindestlänge angleichen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Passwort-Mindestlänge angleichen (alle Endpoints: mindestens 8 Zeichen) |
| **Findings** | MITT-01, SEC-04, SEC-12, NIED-06 |
| **Etappe** | 1 |
| **Status** | ✅ implemented (vollständig, inkl. P-05b) |
| **Letzter Stand** | Version 0.8.66: `PUT /api/auth/pin`, `LoginPage.jsx`, `AccountSettingsPage.jsx`. Version 0.8.67: `POST /api/auth/reset-password` via P-05b. Alle 6 Punkte geschlossen. |
| **Nacharbeit** | P-05b (`reset-password` Mindestlänge) — siehe unten |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-04, SEC-12), §16 (MITT-01); `docs/compliance-implementation.md` §P-05, §P-05b |
| **Hinweise** | P-05 gilt als vollständig geschlossen nach Abschluss von P-05b (2026-05-10). |
#### P-05b Nacharbeit: reset-password Mindestlänge
| Feld | Inhalt |
|------|--------|
| **Typ** | Nacharbeit zu P-05 (kein eigenes Hauptpaket) |
| **Status** | ✅ implemented (2026-05-10, Version 0.8.67) |
| **Befund** | `POST /api/auth/reset-password` hatte kein Mindestlängen-Limit im Backend. |
| **Änderung** | `backend/models.py`: `PasswordResetConfirm.new_password = Field(min_length=8, max_length=128)`. 7 neue Tests. |
| **Verweise** | `docs/compliance-implementation.md` §P-05b; `backend/tests/test_auth_password_reset_minlength.py` |
---
### P-06 Upload-Einwilligungsdialog
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Upload-Einwilligungsdialog (Recht am eigenen Bild, Personen, Minderjährige) |
| **Findings** | KRIT-04 |
| **Etappe** | 1 |
| **Status** | ⚠️ teilweise umgesetzt (KRIT-04 offen) |
| **Letzter Stand** | **Vollständig technisch umgesetzt inkl. P-06+ (2026-05-11, v0.8.83)** unter vorläufigen Erstannahmen `p06-v1-conservative`. **Kernum­setzung (v0.8.75):** Migration 048 (`media_asset_rights_declarations` + 3 Schnellfelder in `media_assets`); Migration 049 (Kontext-Felder); `backend/media_rights.py`; Enforcement in Bulk-Upload, PATCH, Bulk-PATCH, exercises.py; `RightsDeclarationDialog.jsx`; Altbestand-Indikator; 3 neue Admin-Endpoints. **P-06+ Volljournal + Korrektur (v0.8.820.8.83):** Migration 050 (`media_asset_audit_log` + `correction_note` in Deklarations-Tabelle); automatische Audit-Log-Einträge bei Sichtbarkeits-, Copyright-, Metadaten- und Lifecycle-Änderungen; neuer Journal-Endpoint `GET /api/admin/media-rights/assets/{id}/journal` (chronologisch gemischte events[] aus Deklarationen + Audit); neuer Korrektur-Endpoint `POST /api/admin/media-rights/assets/{id}/correction`; Zugriff für Superadmin + Uploader + Vereins-Admin; Korrektur-Formular im Journal-Modal. Juristische Klärungspunkte (§22 KUG, §8 DSGVO, Widerrufsrecht etc.) offen — KRIT-04 bleibt. Details: `docs/p06-upload-rights-spec.md` §10, §11; `docs/compliance-implementation.md` §P-06. |
| **Verweise** | `docs/compliance-audit.md` §8.2, §8.3, §11.4, §17; `docs/p06-upload-rights-spec.md`; `docs/compliance-implementation.md` §P-06; `backend/media_rights.py`; `backend/migrations/048_media_rights_declarations.sql`; `backend/migrations/050_media_audit_log.sql` |
| **Hinweise** | **Drift-Hinweis (2026-05-10):** In `docs/compliance-implementation.md` (vor Korrektur) wurde P-06 fälschlich als „HSTS-Header" beschrieben. Korrigiert. **P-06+ (2026-05-11, v0.8.820.8.83):** Volljournal + Korrektur als notwendige Erweiterung nach Ersterstellung implementiert. Bugfix: Club-Admin-Prüfung im Journal/Korrektur-Endpoint nutzte falsches Schema (`role`-Spalte auf `club_members` existiert nicht) — korrigiert auf `has_club_role()`. |
---
## Etappe 2 Sicherheit und Datenschutz
### P-07 ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Test
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | ALLOW_PUBLIC_MEDIA_STATIC dokumentieren und Release-Test einrichten |
| **Findings** | HOCH-01, SEC-05 |
| **Etappe** | 2 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt in Version 0.8.66. 2 Tests in `test_security_release.py`: Flag deaktiviert per Default; Flag aktiviert mounted `/media`. |
| **Verweise** | `docs/compliance-audit.md` §13.5, §17; `docs/compliance-implementation.md` §P-07; `backend/tests/test_security_release.py` |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-08 HSTS / externe Proxy-Sicherheit dokumentieren
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | HSTS und externe Proxy-Sicherheit dokumentieren |
| **Findings** | HOCH-02, SEC-01 |
| **Etappe** | 2 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. HSTS liegt außerhalb des Repo-Scopes (Reverse-Proxy/Fritz!Box). Betreiber-Verantwortung. |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-01), §19.7 |
| **Hinweise** | **Drift-Hinweis:** In `docs/compliance-implementation.md` (vor Korrektur 2026-05-10) fehlte P-08 vollständig in der „Nicht umgesetzte Pakete"-Tabelle. Der Inhalt „HSTS-Header" war fälschlich P-06 zugeordnet. Korrigiert in `docs/compliance-implementation.md`. |
---
### P-09 Admin-Audit-Log
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Admin-Audit-Log (Protokollierung von Admin-Aktionen) |
| **Findings** | HOCH-05, SEC-07 |
| **Etappe** | 2 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Keine `admin_audit_log`-Tabelle vorhanden. Profil-Löschungen und Lifecycle-Aktionen nicht protokolliert. |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-07), §16 (HOCH-05), §17 |
| **Hinweise** | **Drift-Hinweis:** In `docs/compliance-implementation.md` (vor Korrektur 2026-05-10) wurde P-09 fälschlich als „Kein Einwilligungsdialog Recht am eigenes Bild" beschrieben. Der korrekte Titel ist „Admin-Audit-Log". Der Einwilligungsdialog gehört zu P-06. Korrigiert in `docs/compliance-implementation.md`. |
---
### P-10 Mindestalter-Abfrage
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Mindestalter-Abfrage bei Registrierung |
| **Findings** | HOCH-06 |
| **Etappe** | 2 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Keine Altersverifikation bei Registrierung. Juristisch zu prüfen (§8 DSGVO). |
| **Verweise** | `docs/compliance-audit.md` §11.4, §16 (HOCH-06), §17 |
| **Hinweise** | **Drift-Hinweis:** In `docs/compliance-implementation.md` (vor Korrektur 2026-05-10) wurde P-10 fälschlich als „DSA-Meldeverfahren" beschrieben. Der korrekte Titel ist „Mindestalter-Abfrage". Das DSA-Meldeverfahren gehört zu P-13P-16. Korrigiert in `docs/compliance-implementation.md`. |
---
### P-11 Legal-Hold Lifecycle-Status
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Legal-Hold Lifecycle-Status (Sofortsperrung bei Rechtsverletzung) |
| **Findings** | MITT-02 |
| **Etappe** | 2 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Vollständig umgesetzt in Version 0.8.84 (2026-05-11), mit Nachfixen in 0.8.850.8.86. **Kernum­setzung (0.8.84):** Migration 051 (`legal_hold_active` + Metadatenfelder in `media_assets`; Audit-Log-Ereignistypen `legal_hold_set`/`legal_hold_released`); zentrales Service-Modul `media_legal_hold.py`; Retention-Job überspringt Legal-Hold-Assets; 3 Superadmin-API-Endpoints; Frontend-Badge + Bestätigungs-Dialog + Journal-Renderpfad; 15 Backend-Unit-Tests. **Nachfixe (0.8.85):** UI-Bugs behoben (loadItems, Badge-Sichtbarkeit, Journal-Keys). **Nachfixe (0.8.86):** `download_exercise_media_file` gibt HTTP 451 für Legal-Hold-Assets zurück; `enrich_exercise_detail` liefert `asset_legal_hold_active` mit; `ExerciseMediaEmbed` + `ExerciseMediaThumbTile` + `ExerciseFormPage` zeigen Placeholder statt Datei; Medienliste (`list_media_assets`) nur noch für Superadmin mit Legal-Hold-Einträgen (nicht mehr alle Plattform-Admins). |
| **Verweise** | `docs/compliance-audit.md` §9.2, §16 (MITT-02), §17; `docs/compliance-implementation.md` §P-11; `backend/media_legal_hold.py`; `backend/routers/media_assets.py` (admin_legal_hold_router); `backend/routers/exercises.py` (download_exercise_media_file, enrich_exercise_detail); `backend/migrations/051_legal_hold.sql`; `backend/tests/test_p11_legal_hold.py`; `frontend/src/components/ExerciseMediaEmbed.jsx`; `frontend/src/components/ExerciseMediaThumbTile.jsx` |
| **Hinweise** | Orthogonal zum normalen Papierkorb-Lifecycle (P-03). `rights_status='blocked'` wird als Schnell-Spiegel gesetzt und bei Aufhebung basierend auf vorhandenen Deklarationen wiederhergestellt. Dateiauslieferung über den exercise-Media-Endpoint gibt HTTP 451 zurück — keine Umgehung über direkten Download. Scope abgegrenzt: P-13 (Meldeverfahren), P-14 (DSA-Transparenz), P-15 (DSA-Moderationslog), P-16 (DSA-Beschwerdeweg) bleiben eigene Pakete. |
---
### P-12 sessionStorage bei Logout bereinigen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | sessionStorage bei Logout bereinigen |
| **Findings** | MITT-05 |
| **Etappe** | 2 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt 2026-05-10, Version 0.8.68. `logout()` in `AuthContext.jsx` löscht jetzt alle `sj_coach_*`-Schlüssel via Präfix-Iteration. Gezielte Löschung (kein `sessionStorage.clear()`), fremde Schlüssel bleiben erhalten. Playwright-Test ergänzt in `tests/dev-smoke-test.spec.js`. |
| **Verweise** | `docs/compliance-audit.md` §5.6, §16 (MITT-05), §17; `docs/compliance-implementation.md` §P-12; `frontend/src/context/AuthContext.jsx`; `tests/dev-smoke-test.spec.js` |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-22 HTML-Sanitizer für Rich-Text-Felder
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | HTML-Sanitizer für Rich-Text-Felder (bleach/nh3 Allowlist) |
| **Findings** | NIED-09, SEC-11 |
| **Etappe** | 2 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. `exercise_rich_text.py` normalisiert nur Inline-Media-Markup. Beliebiges HTML in `summary`, `goal`, `execution`, `preparation`, `trainer_notes` möglich. CSP schützt gegen Script-Execution. |
| **Verweise** | `docs/compliance-audit.md` §13.3 (SEC-11), §13.2 (NIED-09), §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-23 LoginPage: minLength angleichen + Versionsstring entfernen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | LoginPage: minLength angleichen und hartcodierten Versionsstring entfernen |
| **Findings** | NIED-06, NIED-07, SEC-12, SEC-13 |
| **Etappe** | 2 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt in Version 0.8.66. `minLength="6"``"8"`; Versionsstring `v0.1.0 • Development` entfernt. |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-12, SEC-13, NIED-06, NIED-07), §17; `docs/compliance-implementation.md` §P-23 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-24 CORS einschränken
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | CORS allow_methods und allow_headers auf tatsächlich benötigte Werte einschränken |
| **Findings** | NIED-08, SEC-14 |
| **Etappe** | 2 |
| **Status** | ✅ implemented |
| **Letzter Stand** | Umgesetzt in Version 0.8.66. `allow_methods=["GET","POST","PUT","PATCH","DELETE","OPTIONS"]`; `allow_headers=["Content-Type","X-Auth-Token","X-Active-Club-Id"]`. |
| **Verweise** | `docs/compliance-audit.md` §13.3, §17; `docs/compliance-implementation.md` §P-24 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
## Etappe 3 DSA-Meldeverfahren
### P-13 Content-Melde-Backend
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Content-Melde-Backend (content_reports-Tabelle + Endpoints) |
| **Findings** | KRIT-03 |
| **Etappe** | 3 → B (vorgezogen; → `docs/compliance-roadmap.md` §4) |
| **Status** | ✅ implemented |
| **Letzter Stand** | Vollständig umgesetzt in Version 0.8.87 (Kernum­setzung) + 0.8.880.8.94 (Erweiterungen + CI-Fix). **Kernum­setzung (0.8.87):** Migration 052 (`content_reports`-Tabelle, Indizes); `POST /api/content-reports` (optionale Auth, official-Medien ohne Login, good_faith_confirmed-Pflicht, auto-Priorisierung); `GET /api/me/inbox/content-reports` (Plattform-Admin, JOIN auf Zieltabellen); `GET /api/content-reports/{id}`; `PATCH /api/content-reports/{id}` (Status/Notiz/Zuweisung, resolution_note Pflicht für Abschluss-Status); `POST /api/content-reports/{id}/legal-hold` (Superadmin, P-11 set_legal_hold(), reason_code-Mapping, setzt Status auf resolved_legal_hold). Inbox-Integration in `InboxPage.jsx`; `ReportContentModal.jsx` + `MediaPreviewModal.jsx` (geteilt). 15 Backend-Unit-Tests. **Erweiterungen (0.8.880.8.94):** Migration 053 (content_report_filed Event-Typ im Audit-Log CHECK-Constraint); E-Mail-Benachrichtigungen nach Meldungseingang (Bestätigung an Melder + alle Plattform-Admins, best-effort); Audit-Log-Einträge bei Meldungseingang, Statuswechsel, reinen Notizänderungen; deutsche Labels (REASON_LABELS_DE, STATUS_LABELS_DE); Club-Admin-Berechtigung (sieht und bearbeitet Meldungen zu Medien eigener Vereine; Legal Hold auf nicht-offizielle Vereinsmedien); frühzeitige 403 für plain Admin in set_legal_hold_from_report (vor DB-Zugriff); Wiedereröffnen einer Meldung (status=submitted setzt reviewed_by/at zurück); WorkflowBar (3-Schritte-Fortschrittsbalken) in InboxPage; Archiv-Trennung (offene vs. abgeschlossene Meldungen, kollabierbar); open_report_count-Badge auf Medienkarten (MediaLibraryPage); Journal-Eintrag content_report_filed in MediaLibraryPage; isClubAdmin/isPlatformAdmin/contentReportsError in OrgInboxContext; onSuccess-Callback in ReportContentModal. **CI-Fix (0.8.94):** 3 pytest-Tests repariert; alle 15 Tests grün. |
| **Verweise** | `docs/compliance-audit.md` §12, §17; `backend/migrations/052_content_reports.sql`; `backend/migrations/053_content_report_audit_event.sql`; `backend/routers/content_reports.py`; `backend/routers/media_assets.py`; `backend/tests/test_p13_content_reports.py`; `frontend/src/pages/InboxPage.jsx`; `frontend/src/context/OrgInboxContext.jsx`; `frontend/src/pages/MediaLibraryPage.jsx`; `frontend/src/components/ReportContentModal.jsx`; `frontend/src/components/MediaPreviewModal.jsx`; `frontend/src/utils/api.js` |
| **Hinweise** | Architekturentscheidung: Die bestehende Admin-Inbox (`InboxPage.jsx`) wurde um einen zweiten Abschnitt erweitert statt einer separaten Moderations-Queue. Club-Admin-Rechte sind bewusst auf Vereinsmedien (nicht official) beschränkt. P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung bei Sperrung — SMTP vorhanden), P-16 (Beschwerdeverfahren) folgen in Etappe D. |
---
### P-14 Moderations-UI
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Moderations-UI (Frontend für Moderation-Queue) |
| **Findings** | KRIT-03 |
| **Etappe** | 3 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Abhängig von P-13. |
| **Verweise** | `docs/compliance-audit.md` §12, §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-15 Uploader-Benachrichtigung bei Sperrung
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Uploader-Benachrichtigung bei Sperrung oder Löschung |
| **Findings** | KRIT-03 |
| **Etappe** | 3 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Kein Benachrichtigungssystem vorhanden. |
| **Verweise** | `docs/compliance-audit.md` §9.2, §12, §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-16 Beschwerdeverfahren
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Beschwerdeverfahren (Nutzer-Rechtsbehelfsweg gegen Moderationsentscheidungen) |
| **Findings** | KRIT-03 |
| **Etappe** | 3 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Abhängig von P-13P-15. |
| **Verweise** | `docs/compliance-audit.md` §12, §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
## Etappe 4 Langfristige Optimierungen
### P-17 MFA für Superadmins (TOTP)
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | MFA für Superadmins (TOTP-Zweifaktorauthentifizierung) |
| **Findings** | HOCH-04, SEC-06 |
| **Etappe** | 4 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Kein TOTP/OTP implementiert. |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-06), §16 (HOCH-04), §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-18 HttpOnly-Cookie als Auth-Alternative
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | HttpOnly-Cookie als Auth-Alternative (XSS-Schutz für Auth-Token) |
| **Findings** | HOCH-03, SEC-02 |
| **Etappe** | 4 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Auth-Token liegt weiterhin in `localStorage`. Größere Architekturänderung erforderlich. |
| **Verweise** | `docs/compliance-audit.md` §10.3, §13.2 (SEC-02), §17 |
| **Hinweise** | **Drift-Hinweis:** In `docs/compliance-implementation.md` (vor Korrektur 2026-05-10) wurde der Inhalt „HttpOnly-Cookie-Migration" fälschlich P-11 zugeordnet. Der korrekte Titel gehört zu P-18. Korrigiert in `docs/compliance-implementation.md`. |
---
### P-19 Anti-Virus-Scan (ClamAV)
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Anti-Virus-Scan für Uploads (ClamAV oder vergleichbar) |
| **Findings** | NIED-02, SEC-10 |
| **Etappe** | 4 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Hoher Aufwand, Risiko bei lokalem Storage gering. |
| **Verweise** | `docs/compliance-audit.md` §13.2 (SEC-10), §16 (NIED-02), §17 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-20 VVT erstellen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | Verzeichnis der Verarbeitungstätigkeiten (VVT) erstellen (DSGVO Art. 30) |
| **Findings** | MITT-03 |
| **Etappe** | 4 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Betreiber-Aufgabe. Identifizierte Verarbeitungsvorgänge in `docs/compliance-audit.md` §11.1. |
| **Verweise** | `docs/compliance-audit.md` §11.1, §17, §19.6 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
### P-21 AV-Verträge abschließen
| Feld | Inhalt |
|------|--------|
| **Kanonischer Titel** | AV-Verträge mit Auftragsverarbeitern abschließen (SMTP, MediaWiki, OpenRouter) |
| **Findings** | MITT-04, MITT-07, MITT-08 |
| **Etappe** | 4 |
| **Status** | ❌ open |
| **Letzter Stand** | Nicht umgesetzt. Betreiber-Aufgabe. Drei identifizierte Auftragsverarbeiter ohne Vertrag. |
| **Verweise** | `docs/compliance-audit.md` §11.3, §17, §19.6 |
| **Hinweise** | Keine Nummerierungsabweichung festgestellt. |
---
## Übersichtstabelle aller Pakete
| ID | Kanonischer Titel | Etappe | Findings | Status |
|----|------------------|--------|----------|--------|
| P-01 | Rechtstexte | 1 | KRIT-01 | ⚠️ partially implemented |
| P-02 | Self-Service-Kontolöschung + Datenexport | 1 | KRIT-02, KRIT-05 | ❌ open |
| P-03 | Papierkorb-Retention-Job aktivieren | 1 | KRIT-07 | ✅ implemented |
| P-03b | _Nacharbeit:_ Retention-Zeiten mit Löschkonzept abgleichen | — | — | ✅ implemented |
| P-04 | Copyright-Pflicht für Archiv-Promotion vereinheitlichen | 1 | KRIT-06 | ✅ implemented |
| P-05 | Passwort-Mindestlänge angleichen | 1 | MITT-01, SEC-04, SEC-12, NIED-06 | ✅ implemented |
| P-05b | _Nacharbeit:_ reset-password Mindestlänge | — | — | ✅ implemented |
| P-06 | Upload-Einwilligungsdialog | 1 | KRIT-04 | ⚠️ teilweise (KRIT-04 offen) |
| P-07 | ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Test | 2 | HOCH-01, SEC-05 | ✅ implemented |
| P-08 | HSTS / externe Proxy-Sicherheit dokumentieren | 2 | HOCH-02, SEC-01 | ❌ open |
| P-09 | Admin-Audit-Log | 2 | HOCH-05, SEC-07 | ❌ open |
| P-10 | Mindestalter-Abfrage | 2 | HOCH-06 | ❌ open |
| P-11 | Legal-Hold Lifecycle-Status | 2 | MITT-02 | ✅ implemented |
| P-12 | sessionStorage bei Logout bereinigen | 2 | MITT-05 | ✅ implemented |
| P-13 | Content-Melde-Backend | 3→B | KRIT-03 | ✅ implemented |
| P-14 | Moderations-UI | 3 | KRIT-03 | ❌ open |
| P-15 | Uploader-Benachrichtigung bei Sperrung | 3 | KRIT-03 | ❌ open |
| P-16 | Beschwerdeverfahren | 3 | KRIT-03 | ❌ open |
| P-17 | MFA für Superadmins (TOTP) | 4 | HOCH-04, SEC-06 | ❌ open |
| P-18 | HttpOnly-Cookie als Auth-Alternative | 4 | HOCH-03, SEC-02 | ❌ open |
| P-19 | Anti-Virus-Scan (ClamAV) | 4 | NIED-02, SEC-10 | ❌ open |
| P-20 | VVT erstellen | 4 | MITT-03 | ❌ open |
| P-21 | AV-Verträge abschließen | 4 | MITT-04, MITT-07, MITT-08 | ❌ open |
| P-22 | HTML-Sanitizer für Rich-Text-Felder | 2 | NIED-09, SEC-11 | ❌ open |
| P-23 | LoginPage: minLength angleichen + Versionsstring entfernen | 2 | NIED-06, NIED-07, SEC-12, SEC-13 | ✅ implemented |
| P-24 | CORS einschränken (Methoden + Header) | 2 | NIED-08, SEC-14 | ✅ implemented |
---
## Fortschritt
**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 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-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.94
**Letztes Umsetzungsdatum:** 2026-05-11
---
*Erstellt: 2026-05-10 | Quelle: `docs/compliance-audit.md` (Initial-Audit 2026-05-09) | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*

364
docs/compliance-roadmap.md Normal file
View File

@ -0,0 +1,364 @@
# Compliance-Roadmap Shinkan Jinkendo
**Typ:** Lebendes Steuerungsdokument
**Erstellt:** 2026-05-10
**App-Version:** 0.8.94
**Zuletzt aktualisiert:** 2026-05-11
---
## 1. Zweck und Geltung
Dieses Dokument steuert die aktuelle Priorisierung, Release-Readiness und nächste Freigabeschritte für das Compliance-Programm. Es ersetzt **keines** der folgenden Dokumente, sondern ergänzt sie:
| Dokument | Rolle |
|----------|-------|
| `docs/compliance-audit.md` | Historischer Initialbefund (unveränderlich als Quelle) |
| `docs/compliance-package-register.md` | Kanonische Quelle für Paket-IDs und aktuellen Paketstatus |
| `docs/compliance-implementation.md` | Chronologischer Umsetzungsnachweis |
| **`docs/compliance-roadmap.md`** | **Aktuelle Prioritäten, Release-Gates, nächste Freigaben** |
**Bindungsregeln:**
- Paket-IDs → immer aus dem Paketregister
- Historische Findings und Severity-Einstufungen → immer aus dem Initial-Audit
- Aktuelle Reihenfolge und Freigabeempfehlungen → **dieses Dokument**
Diese Roadmap ist nach jedem Re-Audit zu aktualisieren. Abweichungen von der bisherigen Priorität werden mit Datum und Begründung dokumentiert (→ §4, §10).
---
## 2. Aktueller Stand (2026-05-11)
### App-Version: 0.8.94
### Teilweise umgesetzte Pakete
| ID | Titel | Version | Offen |
|----|-------|---------|-------|
| P-01 | Rechtstexte | 0.8.74 | Juristische Inhalte — Betreiber + Rechtsanwalt |
| P-06 | Upload-Einwilligungsdialog + P-06+ Volljournal + Korrektur | 0.8.83 | KRIT-04: Juristische Validierung (§22 KUG, §8 DSGVO, Widerrufsrecht, Texte p06-v1-conservative) |
### Vollständig geschlossene Pakete
| ID | Titel | Version |
|----|-------|---------|
| P-03 | Papierkorb-Retention-Job aktivieren | 0.8.66 |
| P-03b | _Nacharbeit:_ Retention-Zeiten 30+30 Tage | 0.8.67 |
| P-04 | Copyright-Pflicht für Archiv-Promotion vereinheitlichen | 0.8.66 |
| P-05 | Passwort-Mindestlänge angleichen (alle Endpoints) | 0.8.660.8.67 |
| P-05b | _Nacharbeit:_ reset-password Mindestlänge | 0.8.67 |
| P-07 | ALLOW_PUBLIC_MEDIA_STATIC dokumentieren + Release-Test | 0.8.66 |
| P-12 | sessionStorage bei Logout bereinigen | 0.8.68 |
| P-23 | LoginPage: minLength angleichen + Versionsstring entfernen | 0.8.66 |
| P-24 | CORS einschränken (Methoden + Header) | 0.8.66 |
| P-01b | _Nacharbeit:_ Mobile/PWA-Erreichbarkeit Rechtstexte via `/settings/legal` | 0.8.70 |
| 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:_ Echter PDF-Download (jsPDF) + Abschnitts-Sortierung/-Einfügen | 0.8.74 |
| P-13 | Content-Melde-Backend + Erweiterungen (Audit-Log, E-Mail, Club-Admin, Workflow) | 0.8.870.8.94 |
**Vollständig abgeschlossen:** 10 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 16 Umsetzungseinheiten (P-13 inkl. umfangreicher Nachfixe 0.8.880.8.94)
**Teilweise umgesetzt (technisch):** P-01, P-06
### Offene Pakete (13)
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
> **⛔ Nicht öffentlich freigabefähig**
**Begründung:** Die App kann im aktuellen Zustand nicht für allgemeine öffentliche Registrierung beworben oder aktiv vermarktet werden. Folgende zwingende Voraussetzungen fehlen noch:
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.
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.~~**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).**
**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.
---
## 3. Aktuelle Blocker vor öffentlichem Betrieb
Die folgenden Pakete sind vor der Freigabe für allgemeine öffentliche Registrierung zwingend zu adressieren. Die Reihenfolge entspricht der in §5 definierten Umsetzungsreihenfolge.
### Blocker 1 — P-01: Rechtstexte
**Finding:** KRIT-01 (Ordnungswidrigkeit, § 5 DDG)
**Technischer Stand:** ⚠️ Vollständige technische Infrastruktur vorhanden (Version 0.8.74):
- Routen `/impressum`, `/datenschutz`, `/nutzungsbedingungen`, `/medienrichtlinie` öffentlich ohne Auth erreichbar (P-01, 0.8.69)
- Mobile/PWA-Erreichbarkeit via `/settings/legal` (P-01b, 0.8.70)
- Admin-konfigurierbare Rechtstexte: DB-Versionierung, Publish/Archive-Workflow, Superadmin-UI `/admin/legal-documents` (P-01c, 0.8.71)
- Als-Entwurf-kopieren: Bestehendes Dokument als Basis für neue Version übernehmen (0.8.72)
- Echter PDF-Download via jsPDF (direkter Dateidownload, kein Druckdialog), Abschnitte sortieren (▲/▼) und an beliebiger Stelle einfügen (0.8.74)
**Warum noch offen:** Impressum, Datenschutzerklärung und AGB ohne juristisch geprüfte Inhalte genügen der gesetzlichen Pflicht nicht. Die Superadmin-UI bietet alle Werkzeuge zum Einpflegen, Versionieren und Veröffentlichen von Texten — sobald Inhalte vorliegen, verschwindet der Platzhalter-Banner automatisch.
**Nächster Schritt:** Rechtsanwalt beauftragen → Inhalte über `/admin/legal-documents` einpflegen → Veröffentlichen → Platzhaltermarkierung verschwindet automatisch.
### Blocker 2 — P-06: Upload-Einwilligungsdialog
**Finding:** KRIT-04 (§22 KUG, §8 DSGVO)
**Warum zwingend:** Jeder hochgeladene Bildinhalt mit erkennbaren Personen ist ohne dokumentierte Einwilligung rechtlich problematisch. Das Risiko besteht nicht erst bei öffentlichen Inhalten — es beginnt beim Upload.
**Technischer Stand:** ⚠️ Vollständig technisch umgesetzt (v0.8.83):
- **P-06aP-06d (v0.8.75):** Deklarations-Log, Backend-Enforcement, `RightsDeclarationDialog.jsx`, 3 neue Admin-Endpoints, Altbestand-Indikator, 25 Backend-Tests
- **P-06+ Volljournal + Korrektur (v0.8.83):** Vollständiger Audit-Log (`media_asset_audit_log`) für alle Asset-Änderungen; Journal-Endpoint (`events[]` chronologisch); Korrektur-Endpoint für nachträgliche Deklarations-Korrekturen; Zugriff Superadmin + Uploader + Vereins-Admin
**Warum noch KRIT-04 offen:** Juristische Validierung der Einwilligungsformulierungen (§7.7), KUG-Anforderungen im Vereinskontext (§7.2), Minderjährigenschutz (§7.4) und Altbestand-Behandlung (§7.8) steht aus. Texte sind Arbeitsfassungen (`p06-v1-conservative`) ohne anwaltliche Freigabe.
**Nächster Schritt:** Rechtsanwalt für juristische Prüfung der Feldtexte (T1T10 in `docs/p06-upload-rights-spec.md` §10.3) und der offenen KUG/DSGVO-Fragen beauftragen. Nach Klärung: Textfreigabe eintragen → KRIT-04 kann geschlossen werden.
### ~~Blocker 3 — P-11: Legal-Hold Lifecycle-Status~~ ✅ Implementiert (v0.8.840.8.86)
**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.
### ~~Blocker 3 (neu) — P-13: Content-Melde-Backend (Minimalversion)~~ ✅ Implementiert (v0.8.87)
**Finding:** KRIT-03
**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.
### Blocker 5 — P-02: DSGVO-Self-Service (fachliche Spezifikation zuerst)
**Finding:** KRIT-02, KRIT-05
**Warum zuerst spezifizieren, dann umsetzen:** P-02 berührt Kontolöschung, Medienarchiv, Vereinsinhalte, Backups und Fristen. Eine voreilige Implementierung riskiert unvollständige oder juristisch falsche Umsetzung. → Erst spezifizieren (→ §5 Etappe C), dann freigeben.
**Juristisch zu klären:** Anonymisierung vs. Löschung bei Vereinsinhalten; Backup-Retention-Policy.
### Blocker 6 — P-08: HSTS-Betriebsnachweis (Betreiber-Aufgabe)
**Finding:** HOCH-02, SEC-01
**Status:** Außerhalb des Repo-Scopes. HSTS muss am externen Reverse-Proxy (Fritz!Box → Synology → Nginx) konfiguriert sein.
**Anforderung:** Vor öffentlichem Betrieb: manueller Nachweis (z. B. `curl -I https://shinkan.jinkendo.de` zeigt `Strict-Transport-Security`-Header).
**Nicht im Code zu lösen** — aber als Release-Gate dokumentiert (→ §7).
---
## 4. Abweichungen gegenüber der ursprünglichen Audit-Reihenfolge
### P-13 wird vorgezogen (Etappe 3 → Etappe B)
Das Initial-Audit (`docs/compliance-audit.md` §17) ordnete P-13P-16 (DSA-Meldeverfahren) in Etappe 3 ein, nach den Sicherheits- und Datenschutzpaketen von Etappe 2. Die Begründung war: juristische Klärung des DSA-Anwendungsbereichs zuerst.
**Aktuelle Einschätzung (2026-05-10):** Diese Reihenfolge bleibt für den vollen DSA-Stack (P-14P-16) richtig. Für den Kern von P-13 — die technische Möglichkeit, eine Inhaltsmeldung abzugeben und zu bearbeiten — ist eine Vorziehung geboten, weil:
- Die App erlaubt bereits jetzt Upload und Anzeige von Nutzerinhalten mit `official`-Sichtbarkeit
- Das Risiko ungemeldeter Rechtsverletzungen ist kein langfristiges, sondern ein unmittelbares
- Die juristische DSA-Bewertung (ab welcher Nutzerzahl, welche Pflichten) bleibt offen — sie entscheidet über den Umfang, nicht über das Ob einer Grundfähigkeit
- Eine minimale Melde-Queue kostet weniger als P-14P-16 zusammen und schafft handlungsfähige Grundstruktur
**Keine ID-Änderung:** P-13 bleibt P-13. Die Neuprioritisierung ist eine Planungsentscheidung, keine inhaltliche Änderung des Pakets.
### P-22 (HTML-Sanitizer) bleibt in Etappe D
Das Initial-Audit platzierte P-22 in Etappe 2. Die aktuelle Einschätzung: CSP `script-src 'self'` reduziert das XSS-Risiko für eingeloggte Nutzer erheblich. Das Risiko ist real, aber kein harter Blocker vor öffentlichem Betrieb im Vergleich zu P-01, P-06, P-11, P-13. P-22 bleibt auf der Roadmap, wird aber nicht als Release-Gate behandelt.
---
## 5. Aktuell empfohlene Umsetzungsreihenfolge
### ✅ Etappe A Abgeschlossen (2026-05-10, Version 0.8.68)
| Paket | Titel | Aufwand | Status |
|-------|-------|---------|--------|
| P-12 | sessionStorage bei Logout bereinigen | ~15 min | ✅ umgesetzt |
**Abschluss:** P-12 wurde am 2026-05-10 in Version 0.8.68 umgesetzt. `logout()` in `AuthContext.jsx` löscht alle `sj_coach_*`-Schlüssel via Präfix-Iteration. Playwright-Test verifiziert. MITT-05 geschlossen.
---
### Etappe B Technische Grundlagen für öffentlichen Betrieb
Reihenfolge innerhalb der Etappe ist flexibel; P-01 und P-06 haben keine gegenseitige Abhängigkeit.
| Paket | Titel | Aufwand | Abhängigkeit | Freigabe-Formulierung |
|-------|-------|---------|-------------|----------------------|
| P-01 | Rechtstexte technisch anlegen (Platzhalter-Seiten, Routen) | 24 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
| P-06 | Upload-Einwilligungsdialog | 24 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
| ~~P-11~~ | ~~Legal-Hold Lifecycle-Status~~ | — | ✅ implementiert (v0.8.84) | — |
| ~~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-13:** ✅ Vollständig implementiert (v0.8.870.8.94). Kernum­setzung: `POST /api/content-reports`, `GET /api/me/inbox/content-reports`, `PATCH /api/content-reports/{id}`, `POST /api/content-reports/{id}/legal-hold`. Erweiterungen: Audit-Log (Migration 053), E-Mail-Benachrichtigungen, Club-Admin-Rechte (Vereinsmedien), Workflow-Bar + Archiv-Trennung, Badge auf Medienkarten, CI-Fix (15 Tests grün). Keine separate Moderations-Queue — bestehende Admin-Inbox erweitert. P-14 (Moderations-UI), P-15 (Uploader-Benachrichtigung), P-16 (Beschwerdeverfahren) folgen in Etappe D.
---
### Etappe C DSGVO-Self-Service sauber spezifizieren (vor Umsetzungsfreigabe)
P-02 erhält eine eigene Spezifikationsphase, bevor eine Umsetzungsfreigabe erteilt wird. Zu klärende Punkte:
| Frage | Entscheidungsträger |
|-------|---------------------|
| Löschung vs. Anonymisierung bei Vereinsinhalten | Betreiber + Rechtsanwalt |
| Verhalten bei Medien, die anderen Vereinen zugeordnet sind | Betreiber + Technik |
| Umgang mit Backups (Retention vs. Löschung auf Antrag) | Betreiber + Rechtsanwalt |
| Fristen: Bearbeitungszeit für Löschanträge | Rechtsanwalt |
| Audit-Log für Löschanträge (wer, wann, was) | Technik |
| Datenexport-Format und Umfang | Technik + Betreiber |
Erst wenn diese Fragen beantwortet und als Spec dokumentiert sind, wird P-02 in Etappe C freigegeben.
---
### Etappe D Ausbau und langfristige Optimierungen
| Paket | Titel | Aufwand | Priorität |
|-------|-------|---------|-----------|
| P-09 | Admin-Audit-Log | 35 Tage | Hoch |
| P-10 | Mindestalter-Abfrage | 12 Tage | Mittel |
| P-14 | Moderations-UI (Frontend) | 35 Tage | Mittel (nach P-13) |
| P-15 | Uploader-Benachrichtigung bei Sperrung | 12 Tage | Mittel (nach P-13) |
| P-16 | Beschwerdeverfahren | 24 Tage | Niedrig (nach P-15) |
| P-08 | HSTS-Dokumentation / Betriebsnachweis | Betreiber | Hoch (Release-Gate) |
| P-22 | HTML-Sanitizer für Rich-Text-Felder | 12 Tage | Mittel |
| P-20 | VVT erstellen | Betreiber | Hoch |
| P-21 | AV-Verträge abschließen | Betreiber | Hoch |
| P-02 | Self-Service-Kontolöschung + Datenexport | 58 Tage | Hoch (nach Spezifikation Etappe C) |
| P-17 | MFA für Superadmins (TOTP) | 58 Tage | Mittel |
| P-18 | HttpOnly-Cookie als Auth-Alternative | 35 Tage | Niedrig |
| P-19 | Anti-Virus-Scan (ClamAV) | 35 Tage | Niedrig |
---
## 6. Nächste empfohlene Freigabe
**P-01 + P-01b + P-01c technisch vollständig umgesetzt (Version 0.8.74).** Rechtstexte über Login, Desktop-Sidebar und Einstellungen → Rechtliches (Mobile/PWA) erreichbar. Superadmin kann über `/admin/legal-documents` versionierte Rechtstexte anlegen, bearbeiten (inkl. Abschnitts-Sortierung und Einfügen an beliebiger Stelle), als Entwurf kopieren, veröffentlichen und als PDF herunterladen — sobald Inhalte eingepflegt sind, verschwindet der Platzhalter-Banner automatisch. Juristische Inhalte bleiben offen (Betreiber + Rechtsanwalt).
Die nächste technische Freigabe sollte **Etappe B** umfassen — beginnend mit P-06:
**P-06 Spezifikation vorhanden** (`docs/p06-upload-rights-spec.md`, 2026-05-10). Die Umsetzung setzt die Entscheidung über 12 juristische Klärungspunkte voraus (§22 KUG im Vereinskontext, §8 DSGVO Minderjährigenschutz, Widerrufsrecht, Speicherfristen für Einwilligungen). Erst nach dieser Klärung:
> „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog"
oder als Paket (nach Klärung P-06):
> „Freigabe zur Umsetzung Etappe B: P-06, P-11, P-13"
**Parallel dazu Betreiber-Aufgabe:**
Rechtsanwalt für die Ausarbeitung der Inhalte von P-01 beauftragen — Inhalte über `/admin/legal-documents` einpflegen und veröffentlichen. Dies ist eine Betreiber-Aufgabe, keine weitere technische Freigabe.
**Nach P-01 vollständig (Inhalte eingepflegt):** Gate 1 rückt näher — verbleibende Blocker: P-06, P-02 (Spez.), P-11, P-13, P-08 (Betreiber).
---
## 7. Release-Gates
### Gate 1 — Öffentliche allgemeine Registrierung freischalten
Folgende Bedingungen müssen **alle** erfüllt sein:
| Bedingung | Paket | Typ |
|-----------|-------|-----|
| Impressum und Datenschutzerklärung mit juristisch geprüftem Inhalt | P-01 | ⚠️ Routen vorhanden — Inhalte durch Rechtsanwalt erforderlich |
| Upload-Einwilligungsdialog aktiv | P-06 | Code |
| DSGVO-Löschprozess spezifiziert (Spec akzeptiert, auch wenn nicht vollständig implementiert) | P-02 | Spezifikation |
| Legal-Hold-Status vorhanden | P-11 | ✅ Bereits erfüllt (Version 0.8.840.8.86) |
| Content-Melde-Backend (Minimalversion) aktiv | P-13 | Code |
| HSTS am externen Reverse-Proxy nachgewiesen | P-08 | Betreiber |
| Papierkorb-Retention-Job läuft (Monitoring aktiv) | P-03 | Bereits erfüllt |
| `ALLOW_PUBLIC_MEDIA_STATIC` nicht in Prod-Env gesetzt | P-07 | Bereits erfüllt |
| sessionStorage bei Logout bereinigt | P-12 | ✅ Bereits erfüllt (Version 0.8.68) |
**Darf bei Gate 1 offen bleiben (mit Dokumentation):**
- P-02 vollständige Implementierung (Spezifikation genügt als Zwischenstand, wenn aktiver Prozess für Löschanfragen per E-Mail dokumentiert ist)
- P-09 Admin-Audit-Log
- P-10 Mindestalter-Abfrage
- P-14P-16 Moderationsausbau
- P-17 MFA, P-18 HttpOnly-Cookie, P-19 Anti-Virus-Scan
- P-20 VVT (in Erstellung), P-21 AV-Verträge (in Klärung)
---
### Gate 2 — Aktivierung öffentlicher Official-Medien (`visibility = official`)
Bevor Inhalte mit `official`-Sichtbarkeit (plattformweit sichtbar, ohne Login) aktiv eingesetzt werden:
| Bedingung | Paket | Typ |
|-----------|-------|-----|
| Gate 1 vollständig erfüllt | — | Voraussetzung |
| Upload-Einwilligungsdialog für Personenbilder aktiv | P-06 | Code |
| Legal-Hold-Status aktiv (Sofortsperrung möglich) | P-11 | ✅ Bereits erfüllt (Version 0.8.840.8.86) |
| Content-Melde-Backend aktiv | P-13 | Code |
| Copyright-Pflicht bei Promotion aktiv und getestet | P-04 | Bereits erfüllt |
| Moderationsprozess organisatorisch dokumentiert (wer, wie schnell, Eskalationspfad) | — | Betreiber |
---
### Gate 3 — Aktivierung öffentlicher Inhalte im Vereinsbereich (`visibility = club`)
Bedingungen für Vereinsinhalte sind weniger streng als für `official`, da der Personenkreis begrenzt ist:
| Bedingung | Status |
|-----------|--------|
| Copyright-Pflicht bei Promotion aktiv | ✅ bereits erfüllt (P-04) |
| Papierkorb-Retention-Job aktiv | ✅ bereits erfüllt (P-03) |
| Upload-Einwilligungsdialog vorhanden | muss mit P-06 kommen |
| Mitgliedschaftsbasierte Zugriffskontrolle funktioniert | ✅ (TenantContext, library_content_visibility_sql) |
---
## 8. Juristisch zu prüfende Punkte
Alle folgenden Punkte erfordern Einschätzung durch einen Rechtsanwalt oder Datenschutzbeauftragten. Keine der nachfolgenden Einschätzungen ist rechtlich verbindlich.
| Thema | Frage | Priorität |
|-------|-------|-----------|
| **Rechtstexte** | Vollständige Angaben Impressum, Datenschutzerklärung, AGB, Medienrichtlinie. Inhalte können nicht technisch generiert werden. | Sofort |
| **DSA-Anwendungsbereich** | Ab welcher Nutzerzahl und unter welchen Voraussetzungen gilt der DSA für diese Plattform? Welche Pflichten für sehr kleine Plattformen? | Vor öffentlichem Betrieb |
| **Einwilligungsmodell Personenbilder** | §22 KUG: Welche Einwilligungen sind für Bilder erkennbarer Personen im Vereinskontext erforderlich? Reicht eine pauschale Upload-Erklärung? | Vor öffentlichem Betrieb |
| **Minderjährigenschutz** | §8 DSGVO: Ab welchem Alter ist keine elterliche Einwilligung nötig? Wie ist mit Bildern Minderjähriger umzugehen? | Vor öffentlichem Betrieb |
| **DSGVO-Löschprozess** | Anonymisierung vs. Löschung bei Vereinsinhalten; Umgang mit Backups; Fristen für Löschanträge (Art. 17) | Vor Implementierung P-02 |
| **Datenübertragbarkeit** | Welcher Exportumfang ist für Art. 20 DSGVO ausreichend? Welches Format? | Vor Implementierung P-02 |
| **AV-Verträge** | Welche Dienstleister benötigen einen AVV (SMTP-Anbieter, MediaWiki, OpenRouter)? | Vor öffentlichem Betrieb |
| **Backup-Retention** | Wie muss die Backup-Retention-Policy aussehen, damit DSGVO-Löschanfragen auch Backups erfassen? | Vor öffentlichem Betrieb |
| **MediaWiki-Lizenz** | Welche Lizenzanforderungen gelten für Übungsinhalte von karatetrainer.net? | Klärenswert |
---
## 9. Betreiber-Aufgaben
Diese Punkte liegen außerhalb des Code-Scopes und erfordern organisatorische Maßnahmen durch den Betreiber:
| Aufgabe | Bezug | Dringlichkeit |
|---------|-------|--------------|
| Rechtsanwalt beauftragen (Rechtstexte, DSA, KUG) | P-01, P-06 | Sofort |
| VVT erstellen (DSGVO Art. 30) | P-20 | Vor öffentlichem Betrieb |
| AV-Verträge klären (SMTP, MediaWiki, OpenRouter) | P-21 | Vor öffentlichem Betrieb |
| HSTS am Reverse-Proxy (Fritz!Box/Synology) konfigurieren und nachweisen | P-08 | Vor öffentlichem Betrieb |
| Backup-Konzept dokumentieren und Restore-Test durchführen | — | Dringend empfohlen |
| Moderationsprozess organisatorisch festlegen: Wer moderiert? Wie schnell? Eskalationspfad? | P-13P-16 | Vor Aktivierung öffentlicher Inhalte |
| Notfallkontakt für Datenpannen benennen (DSGVO Art. 33) | — | Vor öffentlichem Betrieb |
| Papierkorb-Retention-Job überwachen (`docker logs shinkan-retention-cron`) | P-03 | Laufend |
---
## 10. Aktualisierungsregel
| Anlass | Maßnahme |
|--------|----------|
| Nach jedem Re-Audit | §2 (Aktueller Stand) und §5 (Reihenfolge) aktualisieren |
| Nach jeder Freigabe | Paketregister (`docs/compliance-package-register.md`) aktualisieren; §2 dieser Roadmap anpassen |
| Prioritätsänderung gegenüber bisheriger Roadmap | Datum und Begründung in §4 dokumentieren |
| Neue Paket-IDs | Immer im Initial-Audit + Paketregister vergeben; nie nur in dieser Roadmap |
| Paketstatus | Nie direkt in dieser Roadmap pflegen — immer aus Paketregister übernehmen |
**Diese Roadmap enthält keine eigenständigen Statusangaben.** §2 wird durch Abfrage des Paketregisters befüllt und bei Aktualisierungen synchronisiert.
---
## Anhang: Schnellreferenz Freigabe-Formulierungen
| Freigabe | Formulierung | Status |
|----------|-------------|--------|
| ~~P-12 allein~~ | ~~„Freigabe zur Umsetzung P-12: sessionStorage bei Logout bereinigen"~~ | ✅ historisch abgeschlossen (Version 0.8.68) |
| ~~P-01 technisch~~ | ~~„Freigabe zur Umsetzung P-01: Rechtstexte technisch anlegen"~~ | ✅ historisch abgeschlossen (Version 0.8.69) |
| ~~P-01b~~ | ~~„Freigabe zur Umsetzung P-01b: Mobile/PWA-Zugriff auf Rechtliches"~~ | ✅ historisch abgeschlossen (Version 0.8.70) |
| ~~P-01c~~ | ~~„Freigabe zur Umsetzung P-01c: Admin-konfigurierbare Rechtstexte"~~ | ✅ historisch abgeschlossen (Version 0.8.71); Erweiterungen copy-as-draft (0.8.72) + jsPDF/Sortierung (0.8.74) |
| **P-06** | **„Freigabe zur Umsetzung P-06 auf Basis konservativer Erstannahmen"** | ✅ erteilt + vollständig umgesetzt (2026-05-11, v0.8.750.8.83) — technisch umgesetzt unter `p06-v1-conservative` inkl. P-06+ Volljournal + Korrektur; KRIT-04 bleibt bis juristische Validierung |
| **P-11** | **„Freigabe zur Umsetzung P-11: Legal-Hold Lifecycle-Status"** | ✅ vollständig umgesetzt (2026-05-11, v0.8.840.8.86) — Migration 051, Retention-Schutz, Superadmin-API + Frontend (0.8.84); UI-Bugfixes (0.8.85); Auslieferungssperre + Frontend-Placeholder + Superadmin-only Medienliste (0.8.86); 15 Backend-Tests |
| ~~P-13~~ | ~~„Freigabe zur Umsetzung P-13: Content-Melde-Backend"~~ | ✅ vollständig umgesetzt (2026-05-11, v0.8.870.8.94) — Kernum­setzung + Audit-Log, E-Mail-Benachrichtigungen, Club-Admin-Rechte, Workflow-Management, Badge, CI-Fix; 15 Backend-Tests |
| P-02 Spezifikation | „Freigabe zur Spezifikation P-02: DSGVO-Self-Service-Prozess" | offen |
---
*Erstellt: 2026-05-10 | Quellen: `docs/compliance-audit.md`, `docs/compliance-package-register.md`, `docs/compliance-implementation.md` | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*

View File

@ -0,0 +1,984 @@
# P-06 Fachlich-technische Spezifikation: Upload-Einwilligungsdialog
**Paket-ID:** P-06 (kanonisch, unveränderlich)
**Kanonischer Titel:** Upload-Einwilligungsdialog (Recht am eigenen Bild, Personen, Minderjährige)
**Dokument-Typ:** Spezifikation + Umsetzungsnachweis
**Erstellt:** 2026-05-10
**App-Version bei Erstellung:** 0.8.74
**Letzte Aktualisierung:** 2026-05-11 (App-Version 0.8.86, P-11 vollständig inkl. Auslieferungssperre + Frontend-Placeholder)
**Status P-06:** ⚠️ technisch umgesetzt (P-06aP-06d, P-06+) | KRIT-04 offen bis juristische Validierung
**Rechtlicher Hinweis:** Alle als „juristisch zu prüfen" markierten Einschätzungen sind keine Rechtsberatung. Keine konkrete Textfassung von Erklärungen in diesem Dokument — Formulierungen obliegen dem Rechtsanwalt.
---
## 1. Kontext und Ziel
### 1.1 Ausgangslage (Initial-Audit-Befund)
Das Initial-Audit (`docs/compliance-audit.md`, §8.28.3) identifiziert folgende Lücken:
- Keine verbindliche Rechteerklärung beim Medienupload
- Keine Abfrage, ob erkennbare Personen enthalten sind
- Keine Abfrage, ob Minderjährige abgebildet sind
- Keine Abfrage, ob erforderliche Einwilligungen abgebildeter Personen vorliegen
- Keine Dokumentation, wer wann welche Erklärung abgegeben hat
### 1.2 Regulatorische Prüfanker (technische Modellierung)
| Norm | Relevanz für Datenmodell | Juristisch zu prüfen |
|------|--------------------------|----------------------|
| § 22 KUG | Bildnisse erkennbarer Personen erfordern Einwilligung vor Verbreitung/Zurschaustellung | Gilt auch für vereinsinterne Sichtbarkeit? |
| Art. 8 DSGVO | Bei Minderjährigen ggf. Einwilligung Erziehungsberechtigter | Ab welchem Alter? Welche Mechanismen? |
| § 19a UrhG | Öffentliche Zugänglichmachung = eigener Nutzungstatbestand | Ab wann gilt `official` als „öffentlich zugänglich"? |
| DSGVO Art. 5(1)(e) | Datenminimierung — welche Nachweisdaten dürfen gespeichert werden? | Was ist verhältnismäßig? |
### 1.3 Ziel dieser Spezifikation
P-06 soll sicherstellen, dass:
1. Jeder Medien-Upload mit einer strukturierten, gespeicherten Rechteerklärung verknüpft ist
2. Die Erklärung nach Sichtbarkeitsstufe gestuft wird (private minimal, official vollständig)
3. Bestehende Prüfungen aus P-04 (copyright_notice) komplementär erhalten bleiben
4. Juristische Textanpassungen ohne Codeänderungen möglich sind (Versionierung)
5. Altmedien ohne rückwirkenden Zwang behandelt werden können
6. Backend-Erzwingung (keine reine Frontend-Kosmetik) besteht
---
## 2. IST-Analyse
### 2.1 Upload-Pfade (vollständige Bestandsaufnahme)
| # | Endpoint | Datei | Upload-Typ | Sichtbarkeit wählbar | Copyright bei Upload |
|---|----------|-------|-----------|---------------------|----------------------|
| U1 | `POST /api/exercises/{id}/media` | `routers/exercises.py:2518` | Einzeldatei oder Embed-URL | Folgt Übungssichtbarkeit (implizit) | Nicht abgefragt |
| U2 | `POST /api/media-assets/bulk-upload` | `routers/media_assets.py:787` | 1n Dateien | `visibility` + `club_id` als Form-Parameter | Optional (`copyright_notice` fehlt im POST-Body, nur via PATCH nachträglich) |
**Kein weiterer Upload-Endpoint vorhanden** (geprüft über Codebase-Suche nach `UploadFile`).
**Weitere indirekte Upload-Pfade:**
- `POST /api/exercises/{id}/media/from-asset` (`exercises.py`) — verknüpft ein bestehendes Archiv-Asset mit einer Übung; kein Upload, kein neues Medium
- Embed-URL (`embed_url`, `embed_platform`) — keine Datei, keine Rechteerklärung für Dateiinhalte; externe Plattform verantwortlich
### 2.2 Promotionspfade (Sichtbarkeitsänderung)
| # | Endpoint | Datei | Mögliche Promotion |
|---|----------|-------|--------------------|
| PR1 | `PATCH /api/media-assets/{id}` | `routers/media_assets.py:1223` | private→club, private→official, club→official, auch Rückstufung |
| PR2 | `POST /api/media-assets/bulk-patch` | `routers/media_assets.py:1094` | Massenpromotion, gleiche Richtungen |
| PR3 | Implizit bei `PUT /api/exercises/{id}` | `routers/exercises.py` | Übungssichtbarkeit→club/official löst `apply_official_exercise_media_rules()` oder `apply_club_exercise_media_copyright_rules()` aus |
**Rückstufungen** (official→club, club→private) werden nicht gesperrt — juristisch zu prüfen, ob Rückstufung Rechte-Konsequenzen hat.
### 2.3 Datenmodell `media_assets` (Migration 045 + 046)
```
media_assets
├── id SERIAL PK
├── mime_type VARCHAR(100)
├── byte_size INT
├── sha256 CHAR(64) NOT NULL
├── original_filename VARCHAR(300)
├── visibility VARCHAR(32) NOT NULL DEFAULT 'private' ← Sichtbarkeitsstufe
├── club_id INT REFERENCES clubs(id) ← Vereinszuordnung
├── uploaded_by_profile_id INT REFERENCES profiles(id) ← Uploader
├── copyright_notice TEXT ← Urheberrechtshinweis (P-04)
├── storage_backend VARCHAR(32)
├── storage_key TEXT NOT NULL UNIQUE
├── lifecycle_state VARCHAR(32) DEFAULT 'active'
├── trash_soft_at, trash_hidden_at, purge_after_at TIMESTAMP
├── created_at, updated_at TIMESTAMP
└── tags TEXT[] DEFAULT '{}'
```
**Fehlende P-06-Felder:** Keine Einwilligungsfelder vorhanden. Keine Personenabbildungsmarkierung. Keine Deklarations-Versionierung.
### 2.4 Rollen und Berechtigungen (Zusammenfassung `_item_permissions()`)
| Aktion | private | club | official |
|--------|---------|------|----------|
| Upload (neu) | Jeder authentifizierte Nutzer | Vereinsmitglied + club_id | Nur Superadmin |
| `edit_metadata` | Superadmin, Plattform-Admin, Uploader | Superadmin, Plattform-Admin, Club-Admin | Nur Superadmin |
| Promotion zu club | Eigener Upload: Uploader; Club-Asset: Club-Admin | — | — |
| Promotion zu official | Nur Superadmin | Nur Superadmin | — |
| Lifecycle (trash) | Uploader, Plattform-Admin, Superadmin | Club-Admin, Plattform-Admin, Superadmin | Nur Superadmin |
### 2.5 Bestehende Frontend-Dialoge
| Stelle | Datei | Aktuelles Verhalten |
|--------|-------|---------------------|
| Übungsformular Medienupload | `ExerciseFormPage.jsx` | Datei-Picker, kein Einwilligungsdialog |
| Archiv Bulk-Upload | `MediaLibraryPage.jsx` | Datei-Picker + optional `copyright_notice` im Editmodal, kein Pflichtdialog |
| Übung → official (API-Error) | `ExerciseFormPage.jsx:624` | `window.prompt()` für copyright_notice nach 422-Fehler — reaktiv, kein proaktiver Dialog |
| Archiv PATCH Sichtbarkeit | `MediaLibraryPage.jsx` | Backend wirft 400 wenn copyright fehlt; Frontend-Feedback unklar |
**Ergebnis:** Kein proaktiver strukturierter Einwilligungsdialog vorhanden. Alle Copyright-Abfragen sind reaktiv (nach API-Fehler) oder optional.
### 2.6 Bestehende Copyright-Prüfungen P-04 (Referenz)
P-04 erzwingt `copyright_notice` bei Promotion zu `club` oder `official`:
- `patch_media_asset()`: HTTP 400 wenn `copyright_notice` leer bei Ziel club/official
- `bulk_media_patch()`: Asset in `failed`-Liste wenn copyright fehlt
- `apply_official_exercise_media_rules()`: HTTP 422 `OFFICIAL_MEDIA_CONFIRM_REQUIRED` wenn Assets kein copyright
- Mindestlänge: 3 Zeichen (`_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN = 3`)
**Verhältnis zu P-06:** P-04 (copyright_notice) = Attribution für Dritte (wer besitzt das Recht). P-06 = Selbsterklärung des Uploaders (habe ich das Recht, dieses Medium hochzuladen). Ergänzend, nicht konkurrierend.
### 2.7 IST-Lücken (P-06-spezifisch)
| Lücke | Schwere |
|-------|---------|
| Kein Pflichtfeld „Ich bin Rechteinhaber" beim Upload | Kritisch (Grundlage aller Uploads) |
| Keine Abfrage „erkennbare Personen enthalten?" | Hoch (§ 22 KUG-Risiko bei club/official) |
| Keine Abfrage „Minderjährige enthalten?" | Hoch (Art. 8 DSGVO, besonderer Schutz) |
| Keine Speicherung von Einwilligungszeitpunkt und -version | Hoch (Nachweisbarkeit) |
| Keine Erzwingung im Backend (nur Frontend-Checkbox wäre zu wenig) | Kritisch |
| Kein Einwilligungslog (wer hat wann welche Version bestätigt?) | Hoch |
| copyright_notice beim Erstupload nicht abgefragt | Mittel (P-04 fragt erst bei Promotion) |
### 2.8 Altbestandsrisiken
- Alle bestehenden `media_assets`-Zeilen haben keine Einwilligungsmetadaten
- Unbekannt, ob bei bestehenden Medien erkennbare Personen/Minderjährige abgebildet sind
- Medien mit `visibility=club` oder `visibility=official` existieren ohne dokumentierte Einwilligung
- Superadmin kann bestehende official-Medien weiter sehen/nutzen ohne Nachdeklaration
- Bei Neuzuweisung zu einer anderen Übung werden bestehende Medien ohne Prüfung verknüpft
---
## 3. Entscheidungsmatrix
Anforderungen nach **Sichtbarkeitsstufe** (Zeilen) × **Aktion** (Spalten). „Pflicht" = Backend-Sperre wenn fehlend. „Optional" = abfragen aber nicht erzwingen.
| Aktion → | Erstupload | Promotion club | Promotion official | Metadaten-Edit | Reuse in neuer Übung |
|-----------|-----------|---------------|-------------------|---------------|---------------------|
| **private** | `rights_holder`: Pflicht; Personen-Fragen: optional | — | — | Keine P-06-Pflicht | Keine P-06-Pflicht |
| **club** | `rights_holder`: Pflicht; `contains_persons`: Pflicht; `person_consent`: Pflicht wenn Personen | `contains_persons` + `person_consent`: Pflicht; `contains_minors`: Pflicht | — | Keine P-06-Pflicht | Keine P-06-Pflicht |
| **official** | Alles Pflicht | Alles Pflicht | Alle Felder Pflicht + copyright_notice (P-04) | Keine P-06-Pflicht | Keine P-06-Pflicht |
**B: Medieninhalt — Konsequenzen:**
| Inhalt | private | club | official |
|--------|---------|------|----------|
| Keine erkennbaren Personen | `person_consent` entfällt | `person_consent` entfällt | `person_consent` entfällt |
| Erkennbare Erwachsene | Optional zu deklarieren | Einwilligung Pflicht (juristisch zu prüfen) | Einwilligung Pflicht |
| Erkennbare Minderjährige | Optional zu deklarieren | Einwilligung Erziehungsberechtigter Pflicht (juristisch zu prüfen) | Einwilligung Pflicht |
| Unklar / nicht beantwortet | Zulässig bei private | Blockiert bei club/official | Blockiert |
**C: Rechteinhalt — Konsequenzen:**
| Inhalt | Konsequenz |
|--------|-----------|
| Nutzer ist selbst Urheber | `rights_holder_confirmed = true` genügt |
| Nutzer hat Nutzungsrechte | `rights_holder_confirmed = true` genügt; copyright_notice sollte Rechteinhaber nennen |
| Drittmaterial enthalten | `rights_holder_confirmed = true` nur wenn Lizenz vorhanden; copyright_notice Pflicht bei club/official |
| Musik enthalten | `contains_music = true`; `music_rights_confirmed` Pflicht (juristisch zu prüfen) |
| Logos/Marken/sonstige Fremdinhalte | Fällt unter `rights_holder_confirmed`; juristisch zu prüfen |
**D: Aktion — Blockierungslogik:**
| Aktion | Fehlendes Pflichtfeld | Verhalten Backend |
|--------|----------------------|-------------------|
| Upload | rights_holder_confirmed fehlt | HTTP 400 `RIGHTS_DECLARATION_REQUIRED` |
| Promotion club | contains_persons / person_consent fehlt | HTTP 400 `CONSENT_REQUIRED_FOR_CLUB` |
| Promotion official | Beliebiges Pflichtfeld fehlt | HTTP 400 `CONSENT_REQUIRED_FOR_OFFICIAL` |
| Promotion mit Altmedium (legacy) | rights_status = legacy_unreviewed | HTTP 400 `LEGACY_REDECLARATION_REQUIRED` |
---
## 4. Empfohlenes Zielmodell
### 4.1 Neue Tabelle: `media_asset_rights_declarations`
Append-only Audit-Log. Jede Erklärung (Upload, Promotion, Nachdeklaration) erzeugt einen neuen Eintrag. Bestehende Einträge werden nie geändert.
```sql
CREATE TABLE media_asset_rights_declarations (
id SERIAL PRIMARY KEY,
media_asset_id INT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
declared_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
declared_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW(),
-- Kontext
action_type VARCHAR(50) NOT NULL
CHECK (action_type IN (
'upload', -- Erstupload
'promote_club', -- Promotion zu club
'promote_official', -- Promotion zu official
're_declaration', -- Nachdeklaration (auf Aufforderung)
'legacy_re_declaration' -- Altmedium: erste Deklaration nachgereicht
)),
target_visibility VARCHAR(32) NOT NULL, -- Sichtbarkeit für die erklärt wird
declaration_version VARCHAR(20) NOT NULL DEFAULT 'p06-v1.0', -- Textversion der Erklärung
-- Pflichtfeld (alle Sichtbarkeiten)
rights_holder_confirmed BOOLEAN NOT NULL,
-- Personenrechte (Pflicht ab club)
contains_identifiable_persons BOOLEAN,
person_consent_confirmed BOOLEAN, -- Pflicht wenn contains_identifiable_persons = true
contains_minors BOOLEAN,
parental_consent_confirmed BOOLEAN, -- Pflicht wenn contains_minors = true (juristisch zu prüfen)
-- Drittmaterial (optional MVP, Pflicht später)
contains_music BOOLEAN,
music_rights_confirmed BOOLEAN,
third_party_content_confirmed BOOLEAN,
created_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_mar_declarations_asset ON media_asset_rights_declarations (media_asset_id);
CREATE INDEX idx_mar_declarations_profile ON media_asset_rights_declarations (declared_by_profile_id);
```
### 4.2 Neue Felder in `media_assets`
Drei neue Schnellzugriffs-Felder (kein Ersatz für den Log, nur für performante Abfragen):
```sql
ALTER TABLE media_assets
ADD COLUMN rights_status VARCHAR(32)
NOT NULL DEFAULT 'legacy_unreviewed'
CHECK (rights_status IN ('pending', 'declared', 'legacy_unreviewed', 'blocked')),
ADD COLUMN rights_declared_for_visibility VARCHAR(32), -- Höchste Stufe, für die erklärt wurde
ADD COLUMN rights_declared_at TIMESTAMP WITH TIME ZONE; -- Zeitpunkt der aktuellsten Erklärung
```
**Status-Semantik:**
| rights_status | Bedeutung |
|---------------|-----------|
| `legacy_unreviewed` | Bestehendes Medium ohne P-06-Daten — Altbestand |
| `pending` | Neues Medium, Upload noch ohne Deklaration (Zwischenzustand, darf nie persistent sein) |
| `declared` | Gültige Deklaration vorhanden für `rights_declared_for_visibility` |
| `blocked` | Deklaration widerrufen oder durch Admin gesperrt (→ P-11/P-13 Schnittstelle) |
**Visibility-Hierarchie für Prüfung:**
```
private (1) < club (2) < official (3)
```
Promotion ist erlaubt wenn: `rights_declared_for_visibility` ≥ Ziel-Visibility. Sonst ist neue Deklaration erforderlich.
### 4.3 Feld-Bewertung: MVP vs. später
| Feld | MVP-Pflicht | Sinnvoll später | Unnötig / zu früh | Juristisch abhängig |
|------|-------------|-----------------|-------------------|---------------------|
| `rights_holder_confirmed` | ✅ | — | — | Nein |
| `contains_identifiable_persons` | ✅ ab club | — | — | Nein |
| `person_consent_confirmed` | ✅ wenn Personen + ≥ club | — | — | Teilweise |
| `contains_minors` | ✅ ab official | ✅ auch club | — | Ja (Schwelle) |
| `parental_consent_confirmed` | — | ✅ | — | Ja (juristisch zu klären) |
| `contains_music` | — | ✅ | — | Ja |
| `music_rights_confirmed` | — | ✅ | — | Ja |
| `third_party_content_confirmed` | — | ✅ | — | Teilweise |
| `declaration_version` | ✅ | — | — | Nein |
| `action_type` | ✅ | — | — | Nein |
| `rights_status` in media_assets | ✅ | — | — | Nein |
| `rights_declared_for_visibility` | ✅ | — | — | Nein |
| Separates Upload von Einwilligungsdokumenten | — | — | Zu früh | Ja |
| `may_be_embedded_in_exercises` | — | — | Unnötig (exercise_media-Tabelle reicht) | — |
| `declaration_scope_*` als einzelne Booleans | — | — | Unnötig (visibility-String reicht) | — |
### 4.4 Designentscheidungen (mit Begründung)
**Entscheidung 1: Gestufte Erklärungen je Sichtbarkeitsstufe**
Nicht eine universale Erklärung für alle Stufen, sondern Promotion auf höhere Stufe erfordert neue Erklärung.
*Begründung:* Rechtliche Anforderungen steigen mit Sichtbarkeit. Private Medien: nur Eigentümer sieht sie. Official-Medien: plattformweit sichtbar (ggf. öffentlich). Stufenmodell ist rechtsanschlussfähig — spätere juristische Textanpassungen pro Stufe ohne Systemwechsel möglich.
**Entscheidung 2: Minimalset beim Erstupload**
Pflicht beim privaten Upload: nur `rights_holder_confirmed`. Personen-Fragen: optional beim Upload, Pflicht erst bei Promotion zu club/official.
*Begründung:* Die meisten Uploads bleiben privat und werden nie promoted. Volle Fragebogenbelastung beim Upload würde Nutzung hemmen ohne rechtlichen Mehrwert. Personen-Fragen werden erzwungen sobald der Upload für andere sichtbar wird.
**Entscheidung 3: Keine globale Nutzer-Einwilligung (pro Upload)**
Erklärung wird pro Upload abgegeben, nicht einmalig pro Nutzer.
*Begründung:* Jedes Medium ist inhaltlich unterschiedlich. Eine pauschale Einwilligung beim Registrieren oder einmalig je Nutzer deckt nicht ab, ob bei diesem konkreten Video Minderjährige abgebildet sind.
**Entscheidung 4: Append-only Audit-Log**
Einmal abgegebene Erklärungen werden nie geändert oder gelöscht. Neue Erklärung = neuer Eintrag.
*Begründung:* Nachweisbarkeit. Bei späterem Streit muss belegbar sein, was wann erklärt wurde.
**Entscheidung 5: Versionierung via `declaration_version`-String**
Wenn Rechtsanwalt Erklärungstexte ändert, wird die neue Version als "p06-v1.1" deklariert. Keine Datenbank-FK auf eine Textversion-Tabelle (zu komplex für MVP).
*Begründung:* Einfach nachvollziehbar. Ob bei Versionswechsel alle Altdeklarationen erneuert werden müssen, ist juristisch zu entscheiden — technisch ermöglicht das System beides.
**Entscheidung 6: copyright_notice bleibt separat (P-04 unberührt)**
P-06 führt keine copyright_notice-Redundanz ein. copyright_notice (P-04) = Attribution. rights_holder_confirmed (P-06) = Berechtigung. Beide sind Pflicht bei official.
---
## 5. Ziel-Flows
### Flow 1: Erstupload eines privaten Mediums (Übung oder Archiv)
**Frontend:**
1. Nutzer öffnet Upload-Dialog (Exercise oder Archiv)
2. Dateiauswahl
3. **P-06-Dialog erscheint vor Upload-Button-Aktivierung:**
- ☑ „Ich bin der Rechteinhaber oder besitze die erforderlichen Nutzungsrechte an diesem Medium." [Pflicht]
- ☐ „Das Medium enthält erkennbare Personen." [optional]
- → wenn ja: ☑ „Alle abgebildeten Personen haben der Nutzung zugestimmt." [optional bei private, Pflicht bei Promotion]
- ☐ „Das Medium enthält Minderjährige." [optional]
4. Upload-Button aktiv erst wenn Pflicht-Checkbox gesetzt
**Backend:**
- Empfängt neue Felder: `rights_holder_confirmed`, optional `contains_identifiable_persons`, `person_consent_confirmed`, `contains_minors`
- Prüft: `rights_holder_confirmed == true` — sonst HTTP 400 `RIGHTS_DECLARATION_REQUIRED`
- INSERT in `media_assets` mit `rights_status = 'declared'`, `rights_declared_for_visibility = 'private'`, `rights_declared_at = NOW()`
- INSERT in `media_asset_rights_declarations` mit `action_type = 'upload'`, `declaration_version = 'p06-v1.0'`, alle Felder
**Fehlermeldungen:**
- `RIGHTS_DECLARATION_REQUIRED`: "Bitte bestätige, dass du die erforderlichen Rechte an diesem Medium besitzt."
**Tests:**
- Upload ohne `rights_holder_confirmed` → 400
- Upload mit `rights_holder_confirmed=true` → 201, `rights_status='declared'` in DB
- Declaration-Log-Eintrag vorhanden mit korrektem `action_type='upload'`
**Offene juristische Fragen:** Reicht Selbsterklärung oder muss der Betreiber den Upload ansonsten ablehnen? (→ §7.1)
---
### Flow 2: Erstupload direkt als club-Medium (Archiv-Bulk-Upload)
**Frontend:**
- Nutzer wählt `visibility = club` im Bulk-Upload-Dialog
- P-06-Dialog erweitert (zusätzlich zur privaten Pflicht-Checkbox):
- ☑ „Das Medium enthält erkennbare Personen." [Antwort Pflicht bei club]
- → wenn ja: ☑ „Alle abgebildeten Personen haben der vereinsinternen Nutzung zugestimmt." [Pflicht]
- ☑ „Das Medium enthält Minderjährige." [Antwort Pflicht]
- → wenn ja: ☑ „Einwilligung der Erziehungsberechtigten liegt vor." [Pflicht, juristisch zu prüfen]
**Backend:**
- Prüft alle Pflichtfelder für club: `rights_holder_confirmed`, `contains_identifiable_persons` (must be answered), `person_consent_confirmed` (wenn contains_persons=true)
- `rights_declared_for_visibility = 'club'`
- Eintrag in `media_asset_rights_declarations` mit `action_type = 'upload'`, `target_visibility = 'club'`
---
### Flow 3: Bulk-Upload durch Admin (Plattform-Admin / Superadmin)
**Besonderheit:** Admin lädt oft viele Dateien auf einmal. Dialog-Design muss batch-fähig sein.
**Empfehlung:** Eine Einwilligungserklärung für den gesamten Batch (nicht je Datei). Alle Dateien des Batches erhalten den gleichen Declarations-Record, jedoch referenziert je `media_asset_id`.
**Frontend:**
- Vor Upload: Batch-Deklaration (gilt für alle Dateien des Batches)
- Alternativ: Je Datei separate Deklaration (aufwändiger aber präziser) — juristisch zu prüfen welche Variante ausreichend ist
**Backend:**
- `action_type = 'upload'` für alle Dateien des Batches
- Wenn Batch-Deklaration: eine Deklarations-Transaktion, n Einträge in Log (je asset_id)
**Offene juristische Fragen:** Genügt eine Sammelbestätigung für Batches? (→ §7.6)
---
### Flow 4: Promotion private → club
**Frontend:**
1. Nutzer ändert Sichtbarkeit eines Mediums zu `club` (im Archiv oder via Übungssichtbarkeit)
2. Frontend erkennt Sichtbarkeitserhöhung
3. **P-06-Promotions-Dialog:**
- ☑ „Das Medium enthält erkennbare Personen." [Pflicht — Antwort erforderlich]
- → wenn ja: ☑ „Alle abgebildeten Personen haben der vereinsinternen Nutzung zugestimmt." [Pflicht]
- ☑ „Das Medium enthält Minderjährige." [Pflicht — Antwort erforderlich]
- → wenn ja: ☑ „Einwilligung der Erziehungsberechtigten liegt vor." [Pflicht, juristisch zu prüfen]
4. Bestehende copyright_notice-Abfrage (P-04) bleibt erhalten — läuft parallel oder im gleichen Dialog
**Backend:**
- PATCH `/api/media-assets/{id}` empfängt: `visibility='club'`, P-06-Felder, `copyright_notice`
- Prüft: rights_status='declared' AND rights_declared_for_visibility ≥ 'club' — wenn ja, keine neue Deklaration nötig (z. B. bereits als club hochgeladen)
- Wenn nicht: neue Pflichtfelder prüfen, INSERT in Declarations-Log mit `action_type='promote_club'`
- Bestehende P-04-Prüfung: copyright_notice weiterhin Pflicht
- `rights_declared_for_visibility` in media_assets auf 'club' setzen
**Backend-Fehler:**
- `CONSENT_REQUIRED_FOR_CLUB`: Personenfragen nicht beantwortet
- `LEGACY_REDECLARATION_REQUIRED`: Altmedium, muss Nachdeklaration abgeben
- `RIGHTS_DECLARATION_REQUIRED` (P-04): copyright_notice fehlt (bestehend)
---
### Flow 5: Promotion private/club → official
**Frontend:**
1. Nur Superadmin kann zu official promoten
2. Dialog zeigt vollständige Erklärung:
- Alle Felder aus Flow 4 (Pflicht)
- ☑ zusätzlich: „Das Medium enthält Musik oder sonstige Fremdinhalte." [Pflicht — Antwort erforderlich]
- → wenn ja: ☑ „Ich besitze die erforderlichen Lizenzen für die öffentliche Nutzung." [Pflicht]
3. copyright_notice (P-04) weiterhin Pflicht (bestehende Logik)
**Backend:**
- Prüft alle Felder + copyright_notice
- `action_type = 'promote_official'`, `target_visibility = 'official'`
- `rights_declared_for_visibility = 'official'`
**Offene juristische Fragen:** Reicht Selbstdeklaration oder muss Superadmin bei official höhere Sorgfalt nachweisen? (→ §7.3)
---
### Flow 6: Nachbearbeitung von Metadaten (ohne Sichtbarkeitsänderung)
**Szenario:** Nutzer ändert nur `copyright_notice`, `original_filename`, `tags` — keine Sichtbarkeitsänderung.
**P-06-Anforderung:** Keine neue Deklaration erforderlich (Metadaten-Edit ist kein Promotions-Schritt).
**Ausnahme:** Wenn `rights_status = 'blocked'` (durch P-11/P-13 gesetzt) → Metadaten-Edit erlaubt, aber Sichtbarkeitsänderungen bleiben gesperrt.
---
### Flow 7: Verwendung bestehender Medien in neuer Übung (`from-asset`)
**Szenario:** Trainer verknüpft ein bereits im Archiv liegendes Medium mit einer neuen Übung über `POST /api/exercises/{id}/media/from-asset`.
**P-06-Anforderung:** Keine neue Deklaration erforderlich. Die Deklaration gilt für das Medium selbst, nicht für die Verknüpfung. Voraussetzung: `rights_status` des Assets ist 'declared' für die Sichtbarkeit der Übung.
**Backend-Prüfung (neu):**
- Asset-Sichtbarkeit ≥ Übungs-Sichtbarkeit? Bestehende Prüfung.
- `rights_declared_for_visibility` ≥ Übungs-Sichtbarkeit? → Neue Prüfung durch P-06
- Wenn nicht: HTTP 400 `RIGHTS_DECLARATION_REQUIRED_FOR_TARGET_VISIBILITY`
---
### Flow 8: Behandlung von Altmedien ohne P-06-Metadaten
**Strategie: Soft-Block bei Promotion, kein Rückwirkungs-Zwang für private Altmedien**
| Situation | Verhalten |
|-----------|-----------|
| Altmedium private → weiterhin privat | Unverändert zugänglich; keine Zwangs-Nachdeklaration |
| Altmedium privat → Anzeige in Übung (private) | Unverändert; keine neue Deklaration |
| Altmedium privat → Promotion zu club | Blocked: `LEGACY_REDECLARATION_REQUIRED`; Nutzer muss Nachdeklaration abgeben |
| Altmedium club → Promotion zu official | Blocked; Superadmin muss Nachdeklaration abgeben |
| Altmedium mit `rights_status = 'legacy_unreviewed'` | Einsehbar, nicht promotable |
**Nachdeklarations-Flow:**
1. Nutzer versucht Promotion eines Altmediums
2. Backend gibt HTTP 400 `LEGACY_REDECLARATION_REQUIRED` zurück
3. Frontend zeigt Hinweis und Deklarations-Dialog (analog zu neuem Upload-Dialog)
4. Nutzer füllt Deklaration aus
5. PATCH mit `legacy_re_declaration=true` + alle Pflichtfelder + `visibility`
6. Backend: INSERT in Declarations-Log mit `action_type='legacy_re_declaration'`, aktualisiert `rights_status`, `rights_declared_for_visibility`
**Migrationsstrategie für bestehende Daten:**
- Alle bestehenden `media_assets`-Zeilen bleiben mit `rights_status = 'legacy_unreviewed'` (Default in Migration)
- Keine rückwirkende Sperrung
- Bestehende club/official-Medien: `rights_status = 'legacy_unreviewed'` — die historische Sichtbarkeit bleibt erhalten, spätere Änderungen erfordern aber Nachdeklaration
**Juristisch zu prüfen:** Muss der Betreiber aktiv alle Altmedien auf club/official sperren bis zur Nachdeklaration? (→ §7.8)
---
### Flow 9: Widerruf / nachträglich fehlende Rechte erkannt
**Szenario:** Abgebildete Person widerruft Einwilligung oder Uploader erkennt, dass er keine Rechte hatte.
**P-06 implementiert keinen Widerrufsmechanismus direkt.** Anschluss an spätere Pakete:
- **P-11 (Legal-Hold):** Superadmin kann `rights_status = 'blocked'` setzen → Medium sofort aus allen öffentlichen Abfragen entfernt
- **P-13 (Content-Melde-Backend):** Dritte können Rechtsverletzung melden → Moderationsqueue → Superadmin entscheidet über Sperrung
**P-06-Schnittstelle:** Das Feld `rights_status = 'blocked'` in `media_assets` ist die technische Brücke zu P-11 und P-13.
**Empfehlung:** Declaration-Log-Tabelle hat `ON DELETE CASCADE` auf `media_assets.id` — wenn ein Medium purged wird, werden auch alle Deklarationseinträge entfernt. Juristisch zu prüfen, ob Deklarationslog nach Löschung aufbewahrt werden muss (Rechenschaftspflicht Art. 5 Abs. 2 DSGVO).
---
### Flow 10: Zusammenspiel mit P-11 (vollständig implementiert) und P-13
**P-11 Status: ✅ Vollständig implementiert (v0.8.840.8.86)**
| P-06-Element | P-11-Umsetzung | P-13-Anschluss |
|-------------|---------------|---------------|
| `rights_status = 'blocked'` | P-11 setzt diesen Status als Spiegel bei `legal_hold_active=TRUE`; bei Freigabe auf `declared`/`legacy_unreviewed` zurückgesetzt | P-13-Moderator triggert `set_legal_hold()` |
| Declaration-Log | Zeigt, welche Version bei Sperrung galt; wird bei `release_legal_hold` zur Wiederherstellung von `rights_status` genutzt | Kontext für Moderationsentscheidung |
| `contains_identifiable_persons` | Relevant für Löschbegründung (KUG), `reason_code=consent_withdrawn` | Relevanzmerkmal für Meldekategorie |
| `contains_minors` | Höchste Priorität: `reason_code=youth_protection` | CSAM-Eskalationspfad in P-13 |
**Implementiertes Schnittstellenmodell P-06 ↔ P-11 (komplett):**
_Datenhaltung und Audit (v0.8.84):_
- `media_legal_hold.py`: `set_legal_hold()` schreibt `rights_status='blocked'` als Spiegel
- `media_legal_hold.py`: `release_legal_hold()` stellt `rights_status` anhand vorhandener Deklarationen wieder her (`declared` wenn `COUNT(rights_holder_confirmed=TRUE)>0`, sonst `legacy_unreviewed`)
- Audit-Log `media_asset_audit_log` protokolliert `legal_hold_set`/`legal_hold_released` als eigene Ereignistypen (orthogonal zu `copyright_change`, `visibility_change` etc.)
_Sichtbarkeit und Auslieferungssperre (v0.8.850.8.86):_
- `list_media_assets`: `include_legal_hold=is_sup` — nur Superadmin sieht gesperrte Assets in der Medienliste; Plattform-Admins ohne Superadmin-Rolle sehen sie nicht mehr
- `download_exercise_media_file` (v0.8.86): gibt HTTP **451 Unavailable For Legal Reasons** zurück wenn `asset_legal_hold_active=TRUE` — keine Dateiauslieferung, auch wenn das Asset bereits mit einer Übung verknüpft ist
- `enrich_exercise_detail` (v0.8.86): liefert `asset_legal_hold_active` im Media-Array mit — Frontend-Komponenten erhalten den Hold-Status ohne zusätzlichen API-Call
- `ExerciseMediaEmbed.jsx` + `ExerciseMediaThumbTile.jsx` + `ExerciseFormPage.jsx` (v0.8.86): zeigen „Medium nicht verfügbar / Gesperrt"-Placeholder statt Datei, wenn `asset_legal_hold_active=true`
**P-13-Vorbereitung:** `set_legal_hold()` in `media_legal_hold.py` ist eine generische Funktion — P-13 ruft sie mit `reason_code` aus der Moderationsqueue auf, ohne eigene Sperr-Logik implementieren zu müssen. Die Auslieferungssperre (HTTP 451) greift dann automatisch für alle bereits verknüpften Medien.
---
## 6. Legacy- und Migrationskonzept
### 6.1 Fragen und Antworten
| Frage | Empfehlung | Juristisch zu prüfen |
|-------|-----------|----------------------|
| Dürfen Altmedien sichtbar bleiben? | Ja — keine rückwirkende Sperrung | Ja — insb. club/official ohne Einwilligung |
| Dürfen Altmedien in neuen Übungen verwendet werden? | Ja, wenn Sichtbarkeit kompatibel | Ja — falls keine Rechte vorhanden |
| Dürfen Altmedien zu club promoted werden? | Nein — Nachdeklaration erforderlich | Ja — ob Selbstdeklaration genügt |
| Dürfen Altmedien zu official promoted werden? | Nein — vollständige Nachdeklaration | Ja — höhere Anforderungen |
| Muss bei erster Bearbeitung Nachdeklaration erzwungen werden? | Nein (nur bei Promotion) | Ja — Betreiber-Ermessen |
### 6.2 Migration-Schritte
1. **Migration (P-06a):** Neue Spalten in `media_assets` mit `DEFAULT 'legacy_unreviewed'` → alle bestehenden Zeilen erhalten automatisch Status `legacy_unreviewed`
2. **Neue Uploads nach P-06:** Code setzt `rights_status = 'declared'` nach erfolgreicher Deklaration
3. **Altmedien-Promotion:** Backend blockiert mit `LEGACY_REDECLARATION_REQUIRED`; Nutzer muss Nachdeklaration abgeben
4. **Keine automatische Batch-Re-Deklaration** durch Betreiber (wäre fachlich falsch — Betreiber kann nicht für Uploader erklären)
### 6.3 Empfohlene Übergangsstrategie (Minimales Risiko)
```
private Altmedien: weiter sichtbar und nutzbar | Promotion blockiert
club Altmedien: weiter sichtbar | Sichtbarkeitsänderung blockiert
official Altmedien: weiter sichtbar | Sichtbarkeitsänderung blockiert
```
*Begründung:* Vollständige Sperrung aller Altmedien würde Betrieb lahmlegen. Die Blockierung bei Promotion schützt vor Eskalation ohne existierende Nutzung zu zerstören. Juristisch bleibt das Risiko bei bereits exponierten Medien — das ist ein organisatorisches Problem, kein technisches.
---
## 7. Juristische Klärungsliste
Alle folgenden Punkte müssen durch einen Rechtsanwalt oder Datenschutzbeauftragten bewertet werden. Keine der folgenden Einschätzungen ist rechtlich bindend.
| # | Frage | Technische Relevanz | Dringlichkeit |
|---|-------|---------------------|---------------|
| 7.1 | Welche Erklärung ist für **private** Medien (nur Uploader sieht sie) erforderlich? Reicht `rights_holder_confirmed` oder ist auch ohne Sichtbarkeit Dritter eine Personeneinwilligung nötig? | Ob Personen-Fragen bei private Pflicht werden | Vor MVP |
| 7.2 | Welche Erklärung ist für **club**-interne Sichtbarkeit (Vereinsmitglieder) erforderlich? Gilt § 22 KUG für vereinsintern? Reicht Selbsterklärung des Uploaders? | Pflichtfelder bei Promotion zu club | Vor MVP |
| 7.3 | Welche Erklärung ist für **official** (plattformweit, ggf. öffentlich) erforderlich? Reicht Selbsterklärung oder muss Einwilligung nachgewiesen werden? | Pflichtfelder bei Promotion zu official | Vor MVP |
| 7.4 | Wie ist mit erkennbaren **Minderjährigen** zu verfahren? Ab welchem Alter benötigen Minderjährige elterliche Einwilligung? Welche Einwilligungsform ist rechtssicher? | `contains_minors` + `parental_consent_confirmed` — Pflicht ab welcher Stufe? | Vor MVP |
| 7.5 | Reicht eine **Selbsterklärung** des Uploaders oder müssen Einwilligungs-Dokumente (Scan, Upload) archiviert werden? | Dateiupload für Einwilligungs-Scan nötig? (MVP oder später?) | Vor MVP |
| 7.6 | Ist eine **Batch-Deklaration** (eine Erklärung für mehrere Dateien des Uploads) rechtlich ausreichend? | Flow 3: Bulk-Upload-Design | Vor MVP |
| 7.7 | Muss die **Textfassung** der Erklärungen (Checkbox-Labels) durch Rechtsanwalt freigegeben werden? Welche konkreten Formulierungen sind erforderlich? | `declaration_version` Textbasis | Vor MVP |
| 7.8 | Wie sind **Altmedien** zu behandeln, die bereits mit club/official-Sichtbarkeit in der DB stehen? Muss der Betreiber diese sperren bis zur Nachdeklaration? | Migrationsstrategie §6.3 | Zeitnah |
| 7.9 | Wie ist bei **Widerruf** einer Einwilligung vorzugehen? Welche Fristen gelten? Muss das Medium sofort gelöscht oder nur gesperrt werden? | rights_status='blocked', Schnittstelle P-11 | Vor P-11 |
| 7.10 | Welche **Nachweisdaten** dürfen/sollen gespeichert werden (Datensparsamkeit vs. Rechenschaftspflicht)? Wie lange muss der Declaration-Log aufbewahrt werden? | Retention-Policy für `media_asset_rights_declarations` | Vor MVP |
| 7.11 | Welche Anforderungen gelten für **Trainingsvideos mit mehreren Personen**? Muss jede Person einzeln einwilligen oder reicht eine kollektive Erklärung? | `person_consent_confirmed` als Boolean vs. Mehrfach-Einwilligung | Vor MVP |
| 7.12 | Welche Anforderungen gelten für **Musik, Logos, Marken, Grafiken** von Dritten im Video/Bild? Ab wann ist eine separate Lizenz nötig? Reicht `music_rights_confirmed` als Selbsterklärung? | `contains_music`, `music_rights_confirmed` — MVP oder Pflicht? | Mittel |
---
## 8. Umsetzungsplan (für spätere Codephase — keine Umsetzungsfreigabe)
### P-06a Datenmodell / Migration
| Feld | Wert |
|------|------|
| **Ziel** | Neue Tabelle `media_asset_rights_declarations` anlegen; drei neue Spalten in `media_assets` |
| **Betroffene Dateien** | `backend/migrations/048_media_rights_declarations.sql` (neu) |
| **Migrationsbedarf** | Neue Migration; `DEFAULT 'legacy_unreviewed'` automatisch für bestehende Zeilen |
| **Abhängigkeiten** | Keine (neue Tabelle) |
| **Testbedarf** | Migration idempotent; bestehende `media_assets`-Zeilen haben `rights_status='legacy_unreviewed'` nach Migration; neue Zeilen haben `rights_status='declared'` nach P-06b |
| **Akzeptanzkriterien** | Tabelle angelegt, alle Spalten korrekt typisiert, Indizes vorhanden, ALTER-Migration ohne Downtime anwendbar |
| **Risiko** | Niedrig (neue Tabelle + ALTER mit DEFAULT ohne Table-Lock in PostgreSQL 16) |
| **Rollback** | DROP TABLE media_asset_rights_declarations; ALTER TABLE media_assets DROP COLUMN rights_status, rights_declared_for_visibility, rights_declared_at |
---
### P-06b Backend-Erzwingung
| Feld | Wert |
|------|------|
| **Ziel** | Backend blockiert Upload und Promotion wenn Pflichtfelder fehlen; schreibt Declarations-Log |
| **Betroffene Dateien** | `backend/routers/media_assets.py` (bulk-upload + patch + bulk-patch), `backend/routers/exercises.py` (upload_exercise_media, from-asset), neues Modul `backend/media_rights.py` (Hilfslogik) |
| **Neue Pydantic-Models** | `RightsDeclaration` (Pflichtfelder + optionale Felder); Integration in `MediaAssetPatch` + Upload-Endpoints |
| **Neue Fehlercodes** | `RIGHTS_DECLARATION_REQUIRED`, `CONSENT_REQUIRED_FOR_CLUB`, `CONSENT_REQUIRED_FOR_OFFICIAL`, `LEGACY_REDECLARATION_REQUIRED`, `RIGHTS_DECLARATION_REQUIRED_FOR_TARGET_VISIBILITY` |
| **Abhängigkeiten** | P-06a (Migration) muss abgeschlossen sein |
| **Testbedarf** | Unit-Tests für jeden neuen Fehlercode; Integrationstests Upload → Deklarationslog; Promotion ohne/mit Deklaration; Legacy-Block |
| **Akzeptanzkriterien** | Upload ohne `rights_holder_confirmed` → 400; promotion ohne Personen-Deklaration → 400; gültige Deklaration → Eintrag in DB vorhanden; bestehender P-04-Test weiterhin grün |
| **Risiko** | Mittel (Breaking Change für alle Upload-Clients); Feature-Flag zum schrittweisen Rollout empfohlen |
| **Rollback** | Feature-Flag deaktivieren; kein DB-Rollback nötig |
---
### P-06c Frontend-Dialoge
| Feld | Wert |
|------|------|
| **Ziel** | Einwilligungsdialog in allen Upload-Pfaden; Promotions-Dialog bei Sichtbarkeitsänderung |
| **Betroffene Dateien** | `frontend/src/components/RightsDeclarationDialog.jsx` (neu, wiederverwendbar), `frontend/src/pages/ExerciseFormPage.jsx`, `frontend/src/pages/MediaLibraryPage.jsx`, `frontend/src/utils/api.js` |
| **Dialog-Typen** | Upload-Dialog (privat/club/official je nach Kontext), Promotions-Dialog (erkennt Sichtbarkeitserhöhung), Nachdeklarations-Dialog (für Legacy) |
| **Abhängigkeiten** | P-06b (Backend-Codes müssen definiert sein); juristische Textfreigabe für Checkbox-Labels |
| **Testbedarf** | Playwright E2E: Dialog erscheint; Upload ohne Checkbox → deaktivierter Button; Dialog mit allem ausgefüllt → erfolgreicher Upload; Backend-Fehler → korrekte Fehlermeldung |
| **Akzeptanzkriterien** | Dialog erscheint bei allen Upload-Pfaden; Checkbox-State korrekt; Labels entsprechen juristisch geprüften Formulierungen; Promotions-Dialog erkennt Sichtbarkeitserhöhung |
| **Risiko** | Mittel (UX-Änderung an häufig genutzten Pfaden) |
| **Rollback** | Feature-Flag; Dialoge können ohne Backend-Änderung deaktiviert werden |
---
### P-06d Tests / Dokumentation
| Feld | Wert |
|------|------|
| **Ziel** | Vollständige Testabdeckung für P-06 + Doku-Update |
| **Betroffene Dateien** | `backend/tests/test_media_rights_declaration.py` (neu), `tests/dev-smoke-test.spec.js` (neue P-06-Tests), `docs/compliance-implementation.md`, `docs/compliance-package-register.md`, Access-Layer-Audit aktualisieren |
| **Backend-Tests** | Upload-Flows, Promotions-Flows, Legacy-Block, Declaration-Log, alle Fehlercodes |
| **Playwright-Tests** | Dialog erscheint, Checkbox-Validierung, vollständiger Upload-Flow, Promotions-Dialog |
| **Abhängigkeiten** | P-06a, P-06b, P-06c abgeschlossen |
| **Akzeptanzkriterien** | Alle neuen Tests grün; bestehende Tests unverändert grün; ACCESS_LAYER_STRICT=1 grün |
| **Risiko** | Niedrig |
---
### P-06e Legacy-Nacharbeit (optional, nach juristischer Klärung)
| Feld | Wert |
|------|------|
| **Ziel** | Massenprüfung und ggf. Sperrung bestehender club/official-Medien ohne Deklaration, falls juristisch notwendig |
| **Betroffene Dateien** | `backend/scripts/media_rights_audit.py` (neu), evtl. Admin-UI |
| **Inhalt** | Script listet alle `visibility IN ('club', 'official')` mit `rights_status='legacy_unreviewed'`; Superadmin kann Massenmarkierung oder individuelle Überprüfung anstoßen |
| **Abhängigkeiten** | P-06ad; juristische Klärung §7.8 |
| **Akzeptanzkriterien** | Script läuft ohne Fehler; erzeugt CSV-Report mit betroffenen Assets |
| **Risiko** | Mittel (wenn Sperrung aktiv: Betriebsunterbrechung für Vereinsmedien) |
| **Hinweis** | Erst nach juristischer Entscheidung über Altbestand-Behandlung umsetzen |
---
## 9. Abschlussbewertung
### 9.1 Neue / geänderte Dokumente
| Dokument | Änderung |
|----------|---------|
| `docs/p06-upload-rights-spec.md` | Neu erstellt (dieses Dokument) |
| `docs/compliance-roadmap.md` | P-06: Spezifikation erstellt vermerkt; nächste Freigabe angepasst |
| `docs/compliance-implementation.md` | Kurzer P-06-Eintrag: Spezifikation erstellt, keine Umsetzung |
| `docs/compliance-package-register.md` | P-06 Letzter Stand: Spezifikation vorhanden |
### 9.2 Empfohlene P-06-Zielarchitektur (Kurzfassung)
- **Append-only Log-Tabelle** `media_asset_rights_declarations` (Wer, Wann, Was, Welche Version)
- **Drei Status-Felder** in `media_assets`: `rights_status` / `rights_declared_for_visibility` / `rights_declared_at`
- **Gestufte Erklärungen**: Upload = Minimalset; Promotion club = Personen-Pflicht; Promotion official = vollständig
- **Backend-Erzwingung** über neue Fehlercodes; keine reine Frontend-Checkbox
- **Versionierung** über `declaration_version`-String; kein FK auf Texttabelle
- **Legacy**: `rights_status = 'legacy_unreviewed'`; Promotion blockiert; Nachdeklarations-Flow
### 9.3 Kritische Designentscheidungen
| Entscheidung | Begründung |
|-------------|-----------|
| Gestufte Erklärungen je Sichtbarkeitsstufe | Rechtsrisiko steigt mit Sichtbarkeit; Rechtsanschlussfähigkeit |
| Minimalset beim Erstupload | Nutzbarkeit vs. Compliance; Pflicht-Erweiterung bei Promotion |
| Append-only Log (nie löschen/ändern) | Nachweisbarkeit; Audit-Integrität |
| rights_status = 'legacy_unreviewed' per Default | Keine rückwirkende Sperrung; kontrollierbarer Übergang |
| Kein Einwilligungs-Datei-Upload (MVP) | Aufwand vs. Mehrwert; spätere Nachrüstung möglich |
| `rights_status = 'blocked'` als P-11-Schnittstelle | Zukunftssicherheit; P-11/P-13 können sofort anschließen |
### 9.4 Offene juristische Fragen (Priorisiert)
| Priorität | Frage | Paket-Impact |
|-----------|-------|-------------|
| Vor MVP | §7.7 Textfassung der Erklärungen | P-06c (Dialog-Labels) |
| Vor MVP | §7.1 Private: Reicht rights_holder allein? | Pflichtfeld-Definition |
| Vor MVP | §7.2 Club: § 22 KUG vereinsintern? | contains_persons bei club Pflicht? |
| Vor MVP | §7.4 Minderjährige: Schwelle und Form | parental_consent MVP oder später |
| Vor MVP | §7.5 Selbsterklärung oder Dokumenten-Upload? | Scope P-06c |
| Zeitnah | §7.8 Altmedien club/official: Sperrpflicht? | P-06e Scope |
| ~~Vor P-11~~ ✅ | §7.9 Widerruf: Fristen und Mechanismus | P-11 implementiert: `set_legal_hold()` als Sperrmechanismus; juristische Frist-Frage bleibt offen |
| Mittel | §7.10 Aufbewahrung Declaration-Log | Retention-Policy |
### 9.5 Empfohlene spätere Umsetzungsschritte
1. Rechtsanwalt beauftragt für §7.7 (Texte) und §7.17.3 (Anforderungen je Stufe)
2. Nach Klärung §7.7: Freigabe P-06a + P-06b parallel (kein Frontend-Merge nötig)
3. Nach §7.7 Textfreigabe: Freigabe P-06c (Frontend-Dialoge)
4. P-06d: Tests + Doku beim gleichen Merge wie P-06b/P-06c
5. Nach §7.8-Klärung: ggf. P-06e (Legacy-Massenprüfung)
### 9.6 Implementierungsbereitschaft
| Kriterium | Einschätzung |
|-----------|-------------|
| P-06 implementierungsreif | Teilweise — Datenmodell und Backend-Logik spezifizierbar; Dialog-Labels nicht ohne juristische Textfreigabe |
| Juristische Vorabklärung erforderlich | Ja — §7.7 (Textfassung) blockiert P-06c; §7.17.3 bestimmen Pflichtfelder |
| P-06a (Migration) vorab umsetzbar | Ja — unabhängig von juristischen Texten |
| P-06b (Backend) vorab umsetzbar | Ja — mit Platzhalter-Fehlercodes, Texte nachrüstbar |
| P-06c (Frontend-Dialog) vorab umsetzbar | Nein — Dialog-Labels müssen juristisch freigegeben sein |
### 9.7 Scope-Drift-Prüfung (Stand nach vollständiger Umsetzung)
| Prüfung | Ergebnis |
|---------|---------|
| Codeänderungen vorgenommen? | Ja — P-06aP-06d vollständig; P-06+ nachgezogen |
| Neue Migrationen erstellt? | Ja — Migration 048 (Deklarations-Log + Schnellfelder) + Migration 049 (Kontext-Felder) + Migration 050 (Audit-Log + correction_note) |
| Neue Endpoints erstellt? | Ja — 5 neue Endpoints (re-declaration, legacy-summary, legacy-assets, journal, correction) |
| Paket-ID geändert? | Nein |
| Andere Paket-IDs berührt? | Nein (P-11, P-13 nur als Referenz) |
| P-06 bleibt nach dieser Arbeit offen? | Ja — technisch umgesetzt; KRIT-04 (juristische Validierung) bleibt offen |
---
**P-06 technisch umgesetzt (2026-05-11, v0.8.83). KRIT-04 bleibt bis juristische Validierung (§22 KUG, §8 DSGVO, Textfreigabe).**
---
## 10. Vorläufige Implementierungsannahmen (2026-05-11)
> **Wichtiger Hinweis:** Die folgenden Annahmen gelten als vorläufige Erstfassung. Sie wurden bewusst konservativ gewählt. Die juristische Validierung (insb. §7.1§7.7 dieser Spec) steht weiterhin aus. Texte, Pflichtfelder und Stufenlogik können nach juristischer Prüfung gelockert oder präzisiert werden. P-06 gilt nach dieser technischen Umsetzung als **technisch umgesetzt unter vorläufigen Annahmen** — nicht als rechtlich endgültig abgeschlossen. KRIT-04 bleibt bis zur anwaltlichen Prüfung als rechtlicher Blocker offen.
### 10.1 Abweichung gegenüber ursprünglicher Matrix (§3)
Die Entscheidungsmatrix in §3 sah vor:
- **private Upload:** Personenfragen optional
- **club/official Upload:** Personenfragen Pflicht
**Geändert für conservative Erstannahme:**
- Personenfragen sind bei **allen** Uploads (auch `private`) Pflicht zu beantworten
- Begründung: lieber zunächst strenger und nach juristischer Klärung gezielt lockern; ein bereits gesammeltes „Nein, keine erkennbaren Personen" ist besser als gar kein Datenpunkt
- Diese Verschärfung ist als `declaration_version = 'p06-v1-conservative'` dokumentiert
- Spätere Lockerung auf `'p06-v1.1'` o. ä. möglich ohne Datenverlust
### 10.2 Verbindliche Erstannahmen
| Annahme | Technisch umgesetzt als |
|---------|------------------------|
| Jeder Upload (auch `private`) erfordert Rechteerklärung | `rights_holder_confirmed = true` Pflicht bei allen Uploads |
| Personenfragen bei allen Uploads Pflicht | `contains_identifiable_persons` muss beantwortet sein |
| Personeneinwilligung Pflicht wenn Personen vorhanden | `person_consent_confirmed = true` wenn `contains_identifiable_persons = true` |
| Minderjährigenfrage bei allen Uploads Pflicht | `contains_minors` muss beantwortet sein |
| Elterneinwilligung Pflicht wenn Minderjährige vorhanden | `parental_consent_confirmed = true` wenn `contains_minors = true` |
| Musikfrage Pflicht | `contains_music` muss beantwortet sein |
| Musikrechte Pflicht wenn Musik vorhanden | `music_rights_confirmed = true` wenn `contains_music = true` |
| Fremdmaterial-Frage Pflicht | `contains_third_party_content` muss beantwortet sein |
| Fremdmaterialrechte Pflicht wenn Fremdmaterial vorhanden | `third_party_rights_confirmed = true` wenn `contains_third_party_content = true` |
| Erklärung gilt für konkrete Zielsichtbarkeit | `target_visibility` = `private` / `club` / `official` |
| Promotion zu höherer Sichtbarkeit erfordert neue Erklärung | Backend blockiert ohne passende neue Declaration |
| Altmedien bleiben sichtbar | `rights_status = 'legacy_unreviewed'` per Migration-Default |
| Altmedien-Promotion blockiert | HTTP 400 `LEGACY_REDECLARATION_REQUIRED` |
| Selbsterklärung genügt (MVP) | Kein Dokumenten-Upload erforderlich |
| Deklarationsversion | `declaration_version = 'p06-v1-conservative'` |
### 10.3 Vorläufige Dialogtexte (Arbeitsfassung — juristisch nicht geprüft)
Die folgenden Texte sind Arbeitsfassungen und explizit als vorläufig zu behandeln. Sie werden in der UI mit einem entsprechenden Hinweis angezeigt. Alle Labels sind in der Frontend-Komponente zentral definiert und ohne Code-Änderung austauschbar.
| # | Label | Text (Vorläufig) |
|---|-------|-----------------|
| T1 | Rechteinhaber-Bestätigung | „Ich bestätige, dass ich berechtigt bin, dieses Medium hochzuladen und in der gewählten Sichtbarkeitsstufe zu verwenden, und dass ich über die dafür erforderlichen Rechte verfüge." |
| T2 | Personen-Frage | „Sind auf diesem Medium Personen eindeutig erkennbar?" |
| T3 | Personen-Einwilligung | „Ich bestätige, dass mir für alle erkennbaren Personen die für diese Nutzung erforderlichen Einwilligungen vorliegen." |
| T4 | Minderjährigen-Frage | „Sind auf diesem Medium Minderjährige eindeutig erkennbar?" |
| T5 | Minderjährigen-Einwilligung | „Ich bestätige, dass mir die für diese Nutzung erforderlichen Einwilligungen der Sorgeberechtigten vorliegen." |
| T6 | Musik-Frage | „Enthält das Medium Musik?" |
| T7 | Musik-Rechte | „Ich bestätige, dass ich für die enthaltene Musik die für diese Nutzung erforderlichen Rechte habe." |
| T8 | Fremdmaterial-Frage | „Enthält das Medium Logos, Grafiken oder sonstige fremde geschützte Inhalte?" |
| T9 | Fremdmaterial-Rechte | „Ich bestätige, dass ich für alle enthaltenen fremden Inhalte die für diese Nutzung erforderlichen Rechte habe." |
| T10 | Hinweis | „Diese Erklärung wird protokolliert. Die konkrete rechtliche Ausgestaltung wird noch abschließend geprüft." |
### 10.4 Tatsächlich implementierte Endpunkte und Felder
**Migration 048:** `backend/migrations/048_media_rights_declarations.sql`
- Neue Tabelle: `media_asset_rights_declarations` (append-only, alle Felder aus §4.1)
- Neues Feld `rights_status` in `media_assets` (DEFAULT `'legacy_unreviewed'`)
- Neues Feld `rights_declared_for_visibility` in `media_assets`
- Neues Feld `rights_declared_at` in `media_assets`
**Neues Backend-Modul:** `backend/media_rights.py`
- `VISIBILITY_LEVELS`: Hierarchie private(1) < club(2) < official(3)
- `validate_rights_declaration()`: Prüft vollständige Deklaration je Sichtbarkeit
- `check_rights_coverage()`: Prüft ob vorhandene Erklärung Zielsichtbarkeit abdeckt
- `write_rights_declaration()`: Schreibt append-only Declaration-Log
- `update_rights_quick_fields()`: Aktualisiert Schnellfelder in `media_assets`
**Angepasste Endpunkte:**
| Endpunkt | Änderung |
|----------|---------|
| `POST /api/media-assets/bulk-upload` | P-06-Pflichtfelder als Form-Parameter; Declaration-Log bei Erfolg |
| `PATCH /api/media-assets/{id}` | Rechte-Prüfung bei Promotion; Declaration-Log wenn neue Erklärung |
| `POST /api/media-assets/bulk-patch` | Rechte-Prüfung bei Promotion per Asset |
| `POST /api/exercises/{id}/media` | P-06-Pflichtfelder; Declaration-Log bei Datei-Upload |
| `POST /api/exercises/{id}/media/from-asset` | `rights_status`-Prüfung des Assets gegen Übungssichtbarkeit |
**Neue Endpunkte (P-06b):**
| Endpunkt | Beschreibung |
|----------|-------------|
| `POST /api/media-assets/{id}/rights-declarations` | Re-/Nachdeklaration für vorhandene Medien |
| `GET /api/admin/media-rights/legacy-summary` | Auswertung `legacy_unreviewed` nach Sichtbarkeit |
| `GET /api/admin/media-rights/legacy-assets` | Liste `legacy_unreviewed` club/official-Medien |
**Frontend-Komponente:**
- `frontend/src/components/RightsDeclarationDialog.jsx` — wiederverwendbarer Dialog
- Integration in `MediaLibraryPage.jsx` (Bulk-Upload) und `ExerciseFormPage.jsx` (Upload)
**Migration 049 (Kontext-Felder — ergänzend zu 048):**
- `person_consent_context TEXT`, `parental_consent_context TEXT`, `music_rights_context TEXT`, `third_party_rights_context TEXT` — Freitextfelder für Einwilligungskontext (z. B. Einwilligungsreferenz, Datum, Beschreibung)
### 10.5 Juristische Restoffenheit
Folgende Punkte aus §7 sind technisch mit konservativer Annahme implementiert, aber rechtlich noch zu validieren:
| §7-Punkt | Konservative Annahme | Juristisch offen |
|----------|---------------------|-----------------|
| §7.1 private: Reicht rights_holder allein? | Nein — alle Fragen Pflicht | Ja |
| §7.2 club: KUG vereinsintern? | Ja — volle Erklärung Pflicht | Ja |
| §7.4 Minderjährige: Schwelle, Form? | Pflichtfeld ab Upload | Ja |
| §7.5 Selbsterklärung genügt? | Ja (MVP) | Ja |
| §7.6 Batch-Deklaration genügend? | Ja — eine Erklärung gilt für alle Batch-Dateien | Ja |
| §7.7 Textfassung der Erklärungen | Vorläufige Arbeitsfassung (T1T10) | Ja — anwaltliche Freigabe nötig |
| §7.10 Aufbewahrung Declaration-Log | `ON DELETE SET NULL` für declared_by; CASCADE auf media_asset_id | Ja |
---
---
## 11. P-06+ Erweiterungen: Volljournal + Korrektur (2026-05-11, v0.8.820.8.83)
> Diese Erweiterungen gehen über die ursprüngliche P-06-Spezifikation (§8) hinaus und wurden in der Implementierungsphase ergänzt, da das Volljournal und die Korrekturfähigkeit als notwendige Bestandteile eines nachweisfähigen Einwilligungs-Systems erkannt wurden.
### 11.1 Motivation
Die ursprüngliche P-06-Spezifikation sah einen append-only Deklarations-Log vor (§4.1, Entscheidung 4). Nicht explizit geplant war:
- Protokollierung von Nicht-Deklarations-Änderungen (Sichtbarkeit, Copyright, Lifecycle)
- Eine einheitliche chronologische Ansicht aller Ereignisse zu einem Asset
- Die Möglichkeit, abgegebene Deklarationen mit Begründung nachträglich zu korrigieren (bei Irrtum oder Widerruf)
### 11.2 Migration 050: Vollständiger Audit-Log
**Datei:** `backend/migrations/050_media_audit_log.sql`
**Neue Tabelle `media_asset_audit_log`:**
```sql
CREATE TABLE media_asset_audit_log (
id SERIAL PRIMARY KEY,
media_asset_id INT NOT NULL REFERENCES media_assets(id) ON DELETE CASCADE,
acting_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
occurred_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
event_type VARCHAR(50) NOT NULL
CHECK (event_type IN (
'visibility_change',
'copyright_change',
'metadata_change',
'lifecycle_change'
)),
old_values JSONB,
new_values JSONB
);
```
Append-only. Wird nie geändert oder gelöscht (außer ON DELETE CASCADE des Assets).
**Neue Spalte `correction_note`** in `media_asset_rights_declarations`:
```sql
ALTER TABLE media_asset_rights_declarations
ADD COLUMN IF NOT EXISTS correction_note TEXT;
```
**Erweiterter CHECK** in `media_asset_rights_declarations`:
- `action_type` nimmt jetzt auch `'correction'` an (zusätzlich zu upload, promote_club, promote_official, re_declaration, legacy_re_declaration)
### 11.3 Neue Backend-Funktionen (`backend/media_rights.py`)
| Funktion | Beschreibung |
|----------|-------------|
| `write_audit_log_entry(cur, asset_id, acting_profile_id, event_type, old_values, new_values)` | Schreibt einen Eintrag in `media_asset_audit_log` (JSONB-serialisiert) |
| `write_rights_correction_declaration(cur, asset_id, profile_id, target_visibility, decl, correction_note)` | Schreibt action_type='correction' in `media_asset_rights_declarations` |
### 11.4 Neue Endpoints (`backend/routers/media_assets.py`)
| Endpoint | Auth | Beschreibung |
|----------|------|-------------|
| `GET /api/admin/media-rights/assets/{id}/journal` | Superadmin / Uploader / Vereins-Admin | Vollständiges Medien-Journal: Deklarationen + Audit-Ereignisse chronologisch gemischt (`events[]` mit `kind='declaration'` oder `kind='audit'`). Gibt auch `can_correct` zurück. |
| `POST /api/admin/media-rights/assets/{id}/correction` | Superadmin / Uploader / Vereins-Admin | Korrektur-Deklaration einreichen (append-only, neueste gilt). Felder identisch mit P-06-Dialog + `correction_note`. |
**Journal-Response-Format:**
```json
{
"asset": { "id": 57, "original_filename": "...", "visibility": "club", ... },
"events": [
{
"kind": "declaration",
"id": 12,
"at": "2026-05-11T10:00:00Z",
"action_type": "upload",
"target_visibility": "private",
"rights_holder_confirmed": true,
"contains_identifiable_persons": false,
"correction_note": null,
"declared_by_name": "Lars",
"declared_by_email": "stommer@gmail.com"
},
{
"kind": "audit",
"id": 3,
"at": "2026-05-11T11:00:00Z",
"event_type": "visibility_change",
"old_values": {"visibility": "private", "club_id": null},
"new_values": {"visibility": "club", "club_id": 2},
"acting_name": "Lars"
}
],
"can_correct": true
}
```
### 11.5 Automatische Audit-Log-Einträge
| Aktion | event_type | Auslöser |
|--------|-----------|----------|
| Sichtbarkeitsänderung via PATCH | `visibility_change` | `PATCH /api/media-assets/{id}` wenn `visibility` oder `club_id` sich ändert |
| Copyright-Änderung via PATCH | `copyright_change` | `PATCH /api/media-assets/{id}` wenn `copyright_notice` sich ändert |
| Metadaten-Änderung (Dateiname etc.) | `metadata_change` | `PATCH /api/media-assets/{id}` wenn `original_filename` etc. sich ändert |
| Lifecycle-Übergang | `lifecycle_change` | `_apply_lifecycle_action`: trash_soft, trash_hidden, recover, reactivate (nicht bei Hard-Delete oder Purge) |
### 11.6 Frontend-Erweiterungen (`MediaLibraryPage.jsx`)
- **Unified Journal Modal:** Journal-Ansicht zeigt `events[]` chronologisch. Deklarationseinträge und Audit-Einträge visuell unterschieden (`--audit` CSS-Modifier).
- **Korrektur-Formular:** Inline-Formular im Journal-Modal. Erscheint wenn `can_correct = true` und Nutzer „Korrektur erfassen" klickt. Enthält alle P-06-Felder + `correction_note` Freitextfeld.
- **Neue `api.js`-Funktion:** `addMediaAssetDeclarationCorrection(assetId, body)` — POST auf Korrektur-Endpoint.
### 11.7 Zugriffsmodell
Journal und Korrektur sind **nicht** auf Superadmin beschränkt — bewusste Entscheidung:
| Rolle | Zugang Journal | Zugang Korrektur |
|-------|---------------|-----------------|
| Superadmin | ✅ | ✅ |
| Uploader (ursprünglicher Upload) | ✅ | ✅ |
| Vereins-Admin (bei club-Medien) | ✅ | ✅ |
| Andere | ❌ 403 | ❌ 403 |
Club-Admin-Prüfung erfolgt über `has_club_role(cur, profile_id, club_id, "club_admin")` (Tabelle `club_member_roles`), nicht über direktes `role`-Feld in `club_members`.
### 11.8 Bugfixes (2026-05-11)
| Bug | Ursache | Fix |
|-----|---------|-----|
| Journal und Korrektur gaben 500 zurück | Club-Admin-Prüfung nutzte `AND role = 'admin'` auf `club_members` — diese Spalte existiert dort nicht; Rollen liegen in `club_member_roles` | Ersetzt durch `has_club_role(cur, profile_id, club_id, "club_admin")` |
| Frontend-Build-Fehler nach Duplikat `lcLabel` | Funktion `lcLabel` wurde doppelt deklariert (bei Zeile 98 und 264) | Duplikat bei Zeile 264 entfernt |
### 11.9 Juristische Erweiterungsfragen
Die Korrekturfähigkeit von Deklarationen wirft zusätzliche juristische Fragen auf:
| Frage | Technisches Verhalten | Juristisch zu prüfen |
|-------|----------------------|---------------------|
| Wie lange ist eine Korrektur zulässig? | Kein zeitliches Limit implementiert | Fristenregelung? |
| Wer darf eine Korrektur einreichen? | Superadmin, Uploader, Vereins-Admin | Nur Uploader? |
| Gilt die neueste Korrektur immer als maßgeblich? | Ja — `rights_declared_for_visibility` wird aktualisiert | Ja, solange append-only |
| Muss eine Korrektur begründet werden? | `correction_note` optional, nicht Pflicht | Pflichtfeld? |
---
*Erstellt: 2026-05-10 | Implementierungsannahmen hinzugefügt: 2026-05-11 | P-06+ Volljournal + Korrektur: 2026-05-11 (v0.8.820.8.83) | Autor: Claude Code | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*

View File

@ -8,6 +8,7 @@
"preview": "vite preview"
},
"dependencies": {
"jspdf": "^4.2.1",
"lucide-react": "^0.344.0",
"react": "^18.3.1",
"react-dom": "^18.3.1",

View File

@ -37,6 +37,9 @@ import AdminUsersPage from './pages/AdminUsersPage'
import AdminHomeRedirect from './components/AdminHomeRedirect'
import PlatformAdminRoute from './components/PlatformAdminRoute'
import MediaLibraryPage from './pages/MediaLibraryPage'
import LegalPage from './pages/LegalPage'
import AdminLegalDocumentsPage from './pages/AdminLegalDocumentsPage'
import SettingsLegalPage from './pages/SettingsLegalPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import InactiveMembershipBanner from './components/InactiveMembershipBanner'
import './app.css'
@ -171,11 +174,18 @@ function AppRoutes() {
}
/>
{/* P-01: Öffentliche Rechtstextseiten — kein Auth erforderlich */}
<Route path="/impressum" element={<LegalPage type="impressum" />} />
<Route path="/datenschutz" element={<LegalPage type="datenschutz" />} />
<Route path="/nutzungsbedingungen" element={<LegalPage type="nutzungsbedingungen" />} />
<Route path="/medienrichtlinie" element={<LegalPage type="medienrichtlinie" />} />
<Route element={<ProtectedLayout />}>
<Route index element={<Dashboard />} />
<Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="settings/legal" element={<SettingsLegalPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="exercises">
<Route index element={<ExercisesListPage />} />
@ -233,6 +243,14 @@ function AppRoutes() {
</PlatformAdminRoute>
}
/>
<Route
path="admin/legal-documents"
element={
<PlatformAdminRoute>
<AdminLegalDocumentsPage />
</PlatformAdminRoute>
}
/>
<Route path="trainer-contexts" element={<TrainerContextsPage />} />
</Route>

View File

@ -6307,6 +6307,65 @@ a.analysis-split__nav-item {
min-width: 0;
}
/* P-11: Legal Hold */
.media-library__lc-block--legal-hold {
background: rgba(216, 90, 48, 0.08);
border: 1px solid rgba(216, 90, 48, 0.3);
border-radius: 8px;
margin-top: 8px;
padding: 12px 14px 14px;
}
.media-library__legal-hold-badge {
display: inline-flex;
align-items: center;
font-size: 0.68rem;
font-weight: 700;
letter-spacing: 0.03em;
color: #fff;
background: var(--danger);
border-radius: 4px;
padding: 1px 6px;
margin-left: 4px;
text-transform: uppercase;
}
.media-library__legal-hold-info {
font-size: 0.8rem;
color: var(--text2);
margin: 0 0 8px;
}
.media-library__btn--legal-hold {
color: var(--danger);
border-color: var(--danger);
}
.media-library__btn--legal-hold:hover {
background: rgba(216, 90, 48, 0.08);
}
.media-library__btn--legal-hold-confirm {
background: var(--danger);
color: #fff;
border: none;
}
.media-library__btn--legal-hold-confirm:hover {
background: #b84a22;
}
.media-library__legal-hold-dialog-asset {
font-size: 0.9rem;
margin-bottom: 12px;
color: var(--text1);
}
.media-library__legal-hold-warning {
background: rgba(216, 90, 48, 0.08);
border-left: 3px solid var(--danger);
padding: 8px 12px;
border-radius: 0 6px 6px 0;
margin-bottom: 12px;
color: var(--text2);
}
.media-library__modal--narrow {
max-width: 500px;
width: 90%;
}
/* Vorschau-Modal (Vollbild nah) */
.media-library__overlay--preview {
background: rgba(0, 0, 0, 0.72);
@ -6530,3 +6589,147 @@ a.analysis-split__nav-item {
gap: 0.5rem;
flex-shrink: 0;
}
/* Superadmin Media Journal */
.media-library__journal-list {
display: flex;
flex-direction: column;
gap: 12px;
margin-top: 8px;
}
.media-library__journal-entry {
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
padding: 12px 14px;
}
.media-library__journal-entry-head {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-bottom: 4px;
}
.media-library__journal-action {
font-size: 0.78rem;
font-weight: 600;
background: var(--accent);
color: #fff;
border-radius: 4px;
padding: 1px 7px;
letter-spacing: 0.01em;
}
.media-library__journal-arrow {
color: var(--text3);
font-size: 0.85rem;
}
.media-library__journal-vis {
font-size: 0.82rem;
font-weight: 600;
color: var(--text1);
}
.media-library__journal-date {
margin-left: auto;
font-size: 0.78rem;
color: var(--text3);
white-space: nowrap;
}
.media-library__journal-entry-by {
font-size: 0.8rem;
color: var(--text2);
margin-bottom: 8px;
}
.media-library__journal-version {
font-size: 0.74rem;
color: var(--text3);
}
.media-library__journal-fields {
display: flex;
flex-direction: column;
gap: 4px;
}
.media-library__journal-field {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
font-size: 0.82rem;
}
.media-library__journal-field-label {
color: var(--text2);
min-width: 180px;
}
.media-library__journal-field-val {
font-weight: 600;
border-radius: 3px;
padding: 0 5px;
font-size: 0.78rem;
}
.media-library__journal-field-val--yes {
background: rgba(29, 158, 117, 0.12);
color: var(--accent-dark);
}
.media-library__journal-field-val--no {
background: rgba(0, 0, 0, 0.06);
color: var(--text2);
}
.media-library__journal-field-context {
font-size: 0.78rem;
color: var(--text2);
font-style: italic;
}
/* Audit events (Sichtbarkeit, Copyright, Lifecycle) */
.media-library__journal-entry--audit {
border-color: color-mix(in srgb, var(--accent) 30%, var(--border));
background: color-mix(in srgb, var(--accent) 4%, var(--surface2));
}
.media-library__journal-action--audit {
background: var(--text3);
color: var(--surface);
}
.media-library__journal-audit-vals {
font-size: 0.82rem;
color: var(--text1);
margin-top: 4px;
display: flex;
flex-direction: column;
gap: 3px;
}
.media-library__journal-audit-row {
display: flex;
gap: 6px;
}
.media-library__journal-audit-label {
color: var(--text3);
min-width: 40px;
flex-shrink: 0;
}
/* Correction entries */
.media-library__journal-entry--correction {
border-color: color-mix(in srgb, #e0a000 40%, var(--border));
background: color-mix(in srgb, #e0a000 5%, var(--surface2));
}
.media-library__journal-action--correction {
background: #b07800;
color: #fff;
}
.media-library__journal-correction-note {
font-size: 0.82rem;
color: var(--text2);
margin-bottom: 6px;
font-style: italic;
}
/* Correction form */
.media-library__journal-correction-form {
margin-top: 20px;
padding: 16px;
background: var(--surface2);
border: 1px solid var(--border);
border-radius: 8px;
}
.media-library__journal-correction-title {
font-size: 0.95rem;
font-weight: 600;
margin: 0 0 8px;
color: var(--text1);
}

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
import { TreePine, FolderTree, Download, Grid3x3, Users, Scale } from 'lucide-react'
/**
* Admin-Seiten-Navigation (horizontal) nur für Super-Admins (globaler Portal-Mandant).
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download },
{ to: '/admin/legal-documents', label: 'Rechtstexte', icon: Scale },
]
return (

View File

@ -1,4 +1,4 @@
import { NavLink, useLocation } from 'react-router-dom'
import { NavLink, Link, useLocation } from 'react-router-dom'
import { LogOut } from 'lucide-react'
import { getMainNavItems } from '../config/appNav'
import { useOrgInbox } from '../context/OrgInboxContext'
@ -92,6 +92,20 @@ export default function DesktopSidebar({
</button>
</div>
</div>
<div
style={{
padding: '0.5rem 1rem 0.75rem',
display: 'flex',
gap: '0.6rem',
flexWrap: 'wrap',
fontSize: '0.7rem',
}}
>
<Link to="/impressum" style={{ color: 'var(--text3)' }}>Impressum</Link>
<Link to="/datenschutz" style={{ color: 'var(--text3)' }}>Datenschutz</Link>
<Link to="/nutzungsbedingungen" style={{ color: 'var(--text3)' }}>Nutzungsbedingungen</Link>
<Link to="/medienrichtlinie" style={{ color: 'var(--text3)' }}>Medienrichtlinie</Link>
</div>
</aside>
)
}

View File

@ -4,10 +4,10 @@
import React, { useMemo, useState } from 'react'
import ExerciseMediaEmbed from './ExerciseMediaEmbed'
import ExerciseMediaThumbTile from './ExerciseMediaThumbTile'
import MediaPreviewModal from './MediaPreviewModal'
import ReportContentModal from './ReportContentModal'
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
import {
collectInlineExerciseMediaIdsFromExercise,
} from '../utils/exerciseInlineMediaRefs'
import { collectInlineExerciseMediaIdsFromExercise } from '../utils/exerciseInlineMediaRefs'
function isTrashHidden(m) {
return String(m?.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden'
@ -15,6 +15,7 @@ function isTrashHidden(m) {
export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
const [preview, setPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
const inlineIds = useMemo(() => collectInlineExerciseMediaIdsFromExercise(exercise), [exercise])
const orphans = useMemo(() => {
@ -60,63 +61,31 @@ export default function ExerciseAttachmentMediaStrip({ exerciseId, exercise }) {
)
})}
</div>
{preview && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienvorschau"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setPreview(null)}
>
<div
className="card"
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
{preview.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={preview.embed_url} target="_blank" rel="noreferrer">
{preview.embed_url}
</a>
</p>
) : preview.mime_type?.startsWith('video/') || preview.media_type === 'video' ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : preview.mime_type?.startsWith('image/') || preview.media_type === 'image' ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, preview)}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
/>
) : (
<p style={{ fontSize: '14px' }}>
<a href={resolveExerciseMediaFileUrl(exerciseId, preview)} target="_blank" rel="noreferrer">
Datei öffnen
</a>
</p>
)}
<div style={{ marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setPreview(null)}>
Schließen
</button>
</div>
</div>
</div>
<MediaPreviewModal
title={(preview.title || '').trim() || preview.original_filename || `Medium #${preview.id}`}
media={preview}
fileUrl={preview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, preview)}
onClose={() => setPreview(null)}
onReport={
(preview.asset_lifecycle_state || 'active') === 'active' && !preview.asset_legal_hold_active
? () => {
setReportTarget(preview)
setPreview(null)
}
: null
}
/>
)}
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.media_asset_id || reportTarget.id}
targetLabel={reportTarget.title || reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
</section>
)

View File

@ -1,5 +1,6 @@
/**
* Voller Katalog-Inhalt einer Übung (Lesemodus für Coach/Mobile).
* Optional: geplante Variante (`variantId`) Beschreibung und Durchführungsänderungen oben.
*/
import React from 'react'
import { Link } from 'react-router-dom'
@ -51,9 +52,9 @@ function metaParts(exercise) {
}
/**
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number }} props
* @param {{ exercise?: object|null, loading?: boolean, error?: string|null, exerciseId?: number, variantId?: number|string|null }} props
*/
export default function ExerciseFullContent({ exercise, loading, error, exerciseId }) {
export default function ExerciseFullContent({ exercise, loading, error, exerciseId, variantId }) {
if (loading) {
return (
<div style={{ textAlign: 'center', padding: '1rem' }}>
@ -70,8 +71,41 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
const resolvedId = exercise.id ?? exerciseId
const meta = metaParts(exercise)
const variant =
variantId != null && variantId !== '' && Array.isArray(exercise.variants) && exercise.variants.length
? exercise.variants.find((v) => String(v.id) === String(variantId)) || null
: null
return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
{variant ? (
<section
className="card"
style={{
marginBottom: '14px',
padding: '12px 14px',
borderLeft: '3px solid var(--accent)',
background: 'var(--surface2)',
}}
>
<h3 style={{ fontSize: '0.72rem', textTransform: 'uppercase', color: 'var(--text3)', margin: '0 0 6px', letterSpacing: '0.04em' }}>
Geplante Variante
</h3>
<p style={{ margin: '0 0 10px', fontSize: '1.05rem', fontWeight: 700 }}>{variant.variant_name || `Variante #${variant.id}`}</p>
{variant.description ? (
<div style={{ marginBottom: variant.execution_changes ? '12px' : 0 }}>
<h4 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Zur Variante</h4>
<ExerciseRichTextBlock html={variant.description} exerciseId={resolvedId} media={exercise.media} />
</div>
) : null}
{variant.execution_changes ? (
<div>
<h4 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>Durchführung (Variante)</h4>
<ExerciseRichTextBlock html={variant.execution_changes} exerciseId={resolvedId} media={exercise.media} />
</div>
) : null}
</section>
) : null}
<h2 style={{ margin: '0 0 8px', fontSize: '1.2rem', lineHeight: 1.35 }}>{exercise.title}</h2>
{meta.length > 0 && (
<p className="exercise-meta-line" style={{ marginBottom: '10px', color: 'var(--text3)', fontSize: '0.86rem' }}>

View File

@ -10,6 +10,45 @@ import {
} from '../constants/inlineExerciseMedia'
import { sanitizeInlineMediaCaption } from '../utils/inlineMediaCaption'
const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false,
contains_identifiable_persons: null,
person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null,
parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null,
music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null,
third_party_rights_confirmed: false,
third_party_rights_context: '',
}
function validateDecl(decl) {
if (!decl.rights_holder_confirmed)
return 'Bitte bestätigen, dass du die erforderlichen Rechte an diesem Medium besitzt.'
if (decl.contains_identifiable_persons === null)
return 'Bitte angeben, ob erkennbare Personen abgebildet sind.'
if (decl.contains_identifiable_persons && !decl.person_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen aller erkennbaren Personen vorliegen.'
if (decl.contains_minors === null)
return 'Bitte angeben, ob Minderjährige abgebildet sind.'
if (decl.contains_minors && !decl.parental_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen der Sorgeberechtigten vorliegen.'
if (decl.contains_music === null)
return 'Bitte angeben, ob das Medium Musik enthält.'
if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return ''
}
/**
* @param {{
* open: boolean,
@ -30,12 +69,18 @@ export default function ExerciseInlineEmbedModal({
const [title, setTitle] = useState('')
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
const [busy, setBusy] = useState(false)
const [decl, setDecl] = useState({ ...DECL_INIT })
const [declErr, setDeclErr] = useState('')
const setDeclField = (key, val) => setDecl((d) => ({ ...d, [key]: val }))
useEffect(() => {
if (!open) return
setUrl('')
setTitle('')
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
setDecl({ ...DECL_INIT })
setDeclErr('')
}, [open])
const submit = async () => {
@ -44,6 +89,12 @@ export default function ExerciseInlineEmbedModal({
alert('Bitte eine Embed-URL eingeben (https://…).')
return
}
const validErr = validateDecl(decl)
if (validErr) {
setDeclErr(validErr)
return
}
setDeclErr('')
const size = sanitizeInlineMediaSize(displaySize)
const fd = new FormData()
fd.append('embed_url', u)
@ -52,6 +103,9 @@ export default function ExerciseInlineEmbedModal({
fd.append('description', '')
fd.append('context', 'ablauf')
fd.append('is_primary', 'false')
for (const [k, v] of Object.entries(decl)) {
fd.append(k, String(v))
}
setBusy(true)
try {
const row = await api.uploadExerciseMedia(exerciseId, fd)
@ -81,7 +135,7 @@ export default function ExerciseInlineEmbedModal({
role="dialog"
aria-modal="true"
aria-labelledby="rte-inline-embed-title"
style={{ maxWidth: '480px', width: '100%' }}
style={{ maxWidth: '480px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
@ -92,7 +146,7 @@ export default function ExerciseInlineEmbedModal({
Schließen
</button>
</div>
<div style={{ padding: '14px 16px' }}>
<div style={{ overflowY: 'auto', flex: 1, padding: '14px 16px' }}>
<label className="form-label">Embed-URL</label>
<input
type="url"
@ -122,7 +176,109 @@ export default function ExerciseInlineEmbedModal({
</option>
))}
</select>
<button type="button" className="btn btn-primary btn-full" style={{ marginTop: '16px' }} disabled={busy} onClick={submit}>
{/* P-06 Rechte-Erklärung */}
<div style={{ marginTop: '18px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '10px', color: 'var(--text2)' }}>
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span>
</p>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="emb-cr" style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: '3px' }}>Copyright-Angabe (optional)</label>
<input id="emb-cr" type="text" className="form-input" placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice} onChange={(e) => setDeclField('copyright_notice', e.target.value)}
disabled={busy} style={{ fontSize: '0.85rem' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input type="checkbox" id="emb-rhc" checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-rhc" style={{ fontSize: '0.85rem' }}>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label>
</div>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cip" checked={decl.contains_identifiable_persons === true} onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cip" checked={decl.contains_identifiable_persons === false} onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_identifiable_persons === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-pcc" checked={decl.person_consent_confirmed} onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01" value={decl.person_consent_context} onChange={(e) => setDeclField('person_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cm" checked={decl.contains_minors === true} onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cm" checked={decl.contains_minors === false} onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_minors === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-pcc2" checked={decl.parental_consent_confirmed} onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15" value={decl.parental_consent_context} onChange={(e) => setDeclField('parental_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cmu" checked={decl.contains_music === true} onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-cmu" checked={decl.contains_music === false} onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_music === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-mrc" checked={decl.music_rights_confirmed} onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. CC BY 4.0 oder GEMA-Freimeldung Nr. …" value={decl.music_rights_context} onChange={(e) => setDeclField('music_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-ctpc" checked={decl.contains_third_party_content === true} onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="emb-ctpc" checked={decl.contains_third_party_content === false} onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_third_party_content === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="emb-tprc" checked={decl.third_party_rights_confirmed} onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="emb-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10" value={decl.third_party_rights_context} onChange={(e) => setDeclField('third_party_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
{declErr && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '8px' }}>{declErr}</p>
)}
</div>
</div>
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', flexShrink: 0 }}>
<button type="button" className="btn btn-primary btn-full" disabled={busy} onClick={submit}>
{busy ? 'Speichern…' : 'Hinzufügen & in Text einfügen'}
</button>
</div>

View File

@ -60,6 +60,45 @@ function inferExerciseMediaType(file) {
return 'image'
}
const DECL_INIT = {
copyright_notice: '',
rights_holder_confirmed: false,
contains_identifiable_persons: null,
person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null,
parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null,
music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null,
third_party_rights_confirmed: false,
third_party_rights_context: '',
}
function validateDecl(decl) {
if (!decl.rights_holder_confirmed)
return 'Bitte bestätigen, dass du die erforderlichen Rechte an diesem Medium besitzt.'
if (decl.contains_identifiable_persons === null)
return 'Bitte angeben, ob erkennbare Personen abgebildet sind.'
if (decl.contains_identifiable_persons && !decl.person_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen aller erkennbaren Personen vorliegen.'
if (decl.contains_minors === null)
return 'Bitte angeben, ob Minderjährige abgebildet sind.'
if (decl.contains_minors && !decl.parental_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen der Sorgeberechtigten vorliegen.'
if (decl.contains_music === null)
return 'Bitte angeben, ob das Medium Musik enthält.'
if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return ''
}
/**
* @param {{
* open: boolean,
@ -89,6 +128,10 @@ export default function ExerciseInlineFileMediaModal({
const [uploadTitle, setUploadTitle] = useState('')
const [displaySize, setDisplaySize] = useState(DEFAULT_INLINE_MEDIA_SIZE)
const [uploadInputKey, setUploadInputKey] = useState(0)
const [decl, setDecl] = useState({ ...DECL_INIT })
const [declErr, setDeclErr] = useState('')
const setDeclField = (key, val) => setDecl((d) => ({ ...d, [key]: val }))
const assetToExerciseMedia = useMemo(() => {
const m = new Map()
@ -108,7 +151,7 @@ export default function ExerciseInlineFileMediaModal({
limit: 48,
lifecycle: 'active',
})
setItems(Array.isArray(res.items) ? res.items : [])
setItems(Array.isArray(res.items) ? res.items.filter(a => !a.legal_hold_active) : [])
} catch (e) {
setErr(e.message || String(e))
setItems([])
@ -126,6 +169,8 @@ export default function ExerciseInlineFileMediaModal({
setUploadInputKey((k) => k + 1)
setDisplaySize(DEFAULT_INLINE_MEDIA_SIZE)
setErr(null)
setDecl({ ...DECL_INIT })
setDeclErr('')
const t = setTimeout(loadAssets, 280)
return () => clearTimeout(t)
}, [open])
@ -187,6 +232,12 @@ export default function ExerciseInlineFileMediaModal({
alert('Bitte eine Datei wählen.')
return
}
const validErr = validateDecl(decl)
if (validErr) {
setDeclErr(validErr)
return
}
setDeclErr('')
const size = sanitizeInlineMediaSize(displaySize)
const inferred = inferExerciseMediaType(uploadFile)
const fd = new FormData()
@ -196,6 +247,9 @@ export default function ExerciseInlineFileMediaModal({
fd.append('description', '')
fd.append('context', 'ablauf')
fd.append('is_primary', 'false')
for (const [k, v] of Object.entries(decl)) {
fd.append(k, String(v))
}
setBusy(true)
setErr(null)
try {
@ -321,7 +375,7 @@ export default function ExerciseInlineFileMediaModal({
})}
</div>
{!loading && items.length === 0 ? (
<p style={{ color: 'var(--text3)', marginTop: '12px' }}>Keine Treffer Suche anpassen oder Neu hochladen.</p>
<p style={{ color: 'var(--text3)', marginTop: '12px' }}>Keine Treffer Suche anpassen oder Neu hochladen".</p>
) : null}
</>
)}
@ -358,6 +412,107 @@ export default function ExerciseInlineFileMediaModal({
onChange={(e) => setUploadTitle(e.target.value)}
disabled={busy}
/>
{/* P-06 Rechte-Erklärung */}
<div style={{ marginTop: '18px', paddingTop: '14px', borderTop: '1px solid var(--border)' }}>
<p style={{ fontSize: '0.82rem', fontWeight: 600, marginBottom: '10px', color: 'var(--text2)' }}>
Rechte-Erklärung <span style={{ fontWeight: 400, color: 'var(--text3)' }}>(VORLÄUFIG p06-v1-conservative)</span>
</p>
<div style={{ marginBottom: '10px' }}>
<label htmlFor="up-cr" style={{ display: 'block', fontSize: '0.82rem', fontWeight: 600, marginBottom: '3px' }}>Copyright-Angabe (optional)</label>
<input id="up-cr" type="text" className="form-input"
placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice}
onChange={(e) => setDeclField('copyright_notice', e.target.value)}
disabled={busy} style={{ fontSize: '0.85rem' }} />
</div>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '10px' }}>
<input type="checkbox" id="up-rhc" checked={decl.rights_holder_confirmed}
onChange={(e) => setDeclField('rights_holder_confirmed', e.target.checked)}
disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-rhc" style={{ fontSize: '0.85rem' }}>
Ich bestätige, dass ich die erforderlichen Rechte an diesem Medium besitze oder zur Veröffentlichung berechtigt bin. *
</label>
</div>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === true} onChange={() => setDeclField('contains_identifiable_persons', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cip" checked={decl.contains_identifiable_persons === false} onChange={() => setDeclField('contains_identifiable_persons', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_identifiable_persons === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-pcc" checked={decl.person_consent_confirmed} onChange={(e) => setDeclField('person_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-pcc" style={{ fontSize: '0.85rem' }}>Einwilligungen aller erkennbaren Personen liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01" value={decl.person_consent_context} onChange={(e) => setDeclField('person_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === true} onChange={() => setDeclField('contains_minors', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cm" checked={decl.contains_minors === false} onChange={() => setDeclField('contains_minors', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_minors === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-pcc2" checked={decl.parental_consent_confirmed} onChange={(e) => setDeclField('parental_consent_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-pcc2" style={{ fontSize: '0.85rem' }}>Einwilligungen der Sorgeberechtigten liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Einwilligungskontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15" value={decl.parental_consent_context} onChange={(e) => setDeclField('parental_consent_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '10px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === true} onChange={() => setDeclField('contains_music', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-cmu" checked={decl.contains_music === false} onChange={() => setDeclField('contains_music', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_music === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-mrc" checked={decl.music_rights_confirmed} onChange={(e) => setDeclField('music_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-mrc" style={{ fontSize: '0.85rem' }}>Musikrechte (GEMA / Lizenz) liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Lizenz / GEMA-Kontext (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. CC BY 4.0 oder GEMA-Freimeldung Nr. …" value={decl.music_rights_context} onChange={(e) => setDeclField('music_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
<fieldset style={{ border: 'none', padding: 0, marginBottom: '4px' }}>
<legend style={{ fontSize: '0.85rem', fontWeight: 600, marginBottom: '4px' }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: '14px', marginBottom: '4px' }}>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === true} onChange={() => setDeclField('contains_third_party_content', true)} disabled={busy} /> Ja</label>
<label style={{ fontSize: '0.85rem' }}><input type="radio" name="up-ctpc" checked={decl.contains_third_party_content === false} onChange={() => setDeclField('contains_third_party_content', false)} disabled={busy} /> Nein</label>
</div>
{decl.contains_third_party_content === true && (
<div style={{ paddingLeft: 2 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '8px', marginBottom: '4px' }}>
<input type="checkbox" id="up-tprc" checked={decl.third_party_rights_confirmed} onChange={(e) => setDeclField('third_party_rights_confirmed', e.target.checked)} disabled={busy} style={{ marginTop: '3px', flexShrink: 0 }} />
<label htmlFor="up-tprc" style={{ fontSize: '0.85rem' }}>Rechte an allen enthaltenen Fremdmaterialien liegen vor. *</label>
</div>
<label style={{ display: 'block', fontSize: '0.8rem', color: 'var(--text2)', marginBottom: '2px' }}>Rechtsgrundlage (optional)</label>
<textarea className="form-input" rows={2} placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10" value={decl.third_party_rights_context} onChange={(e) => setDeclField('third_party_rights_context', e.target.value)} disabled={busy} style={{ fontSize: '0.82rem', resize: 'vertical' }} />
</div>
)}
</fieldset>
{declErr && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: '8px' }}>{declErr}</p>
)}
</div>
</>
)}
</div>

View File

@ -16,6 +16,15 @@ export default function ExerciseMediaEmbed({ exerciseId, media, layoutSize = 'me
: { maxWidth: 'min(560px, 85vw)', marginTop: '0.5rem' }
if (!media || exerciseId == null) return null
if (media.asset_legal_hold_active) {
return (
<div style={{ ...box, color: 'var(--danger)', fontSize: '0.85rem', padding: '8px 0' }}>
Medium nicht verfügbar (gesperrt)
</div>
)
}
if (media.embed_url) {
return (
<div

View File

@ -11,6 +11,32 @@ export default function ExerciseMediaThumbTile({ exerciseId, media, onOpenPrevie
height: '100%',
objectFit: 'cover',
}
if (media.asset_legal_hold_active) {
return (
<div
style={{
width: size,
height: size,
flexShrink: 0,
borderRadius: '8px',
overflow: 'hidden',
background: 'rgba(216, 90, 48, 0.10)',
border: '1px solid rgba(216, 90, 48, 0.35)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
title: 'Gesperrt',
}}
title="Medium gesperrt"
>
<span style={{ fontSize: '11px', color: 'var(--danger)', textAlign: 'center', padding: '4px' }}>
Gesperrt
</span>
</div>
)
}
return (
<div
role="button"

View File

@ -0,0 +1,140 @@
/**
* Gemeinsamer Medienvorschau-Dialog.
*
* Props:
* title Anzeigename des Mediums (Dateiname o.ä.)
* media Medienobjekt (mime_type, embed_url, asset_legal_hold_active, media_type)
* fileUrl Bereits aufgelöste Datei-URL (null für Embed-only-Medien)
* onClose Pflicht
* onReport optional; wenn gesetzt, erscheint "Melden"-Button
* onEdit optional; wenn gesetzt, erscheint "Bearbeiten"-Button
*/
import React from 'react'
export default function MediaPreviewModal({ title, media, fileUrl, onClose, onReport, onEdit }) {
const mlow = (media?.mime_type || '').toLowerCase()
const isHeic = mlow.includes('heic') || mlow.includes('heif')
const isImage = !isHeic && (media?.mime_type?.startsWith('image/') || media?.media_type === 'image')
const isVideo = media?.mime_type?.startsWith('video/') || media?.media_type === 'video'
const isPdf = mlow.includes('pdf')
const isLegalHold = !!media?.asset_legal_hold_active
const canReport = onReport && !isLegalHold
function handleBackdrop(e) {
if (e.target === e.currentTarget) onClose()
}
function handleKeyDown(e) {
if (e.key === 'Escape') onClose()
}
return (
<div
role="dialog"
aria-modal="true"
aria-label="Medienvorschau"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 1100,
overflow: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={handleBackdrop}
onKeyDown={handleKeyDown}
>
<div
className="card"
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto', padding: '1.25rem' }}
onClick={(e) => e.stopPropagation()}
>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem', gap: '0.5rem' }}>
<h3 style={{ margin: 0, fontSize: '1rem', fontWeight: 600, overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
{title || 'Vorschau'}
</h3>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
{canReport && (
<button type="button" className="btn btn-secondary" style={{ fontSize: '0.85rem', padding: '5px 10px' }} onClick={onReport}>
Melden
</button>
)}
{onEdit && (
<button type="button" className="btn btn-secondary" style={{ fontSize: '0.85rem', padding: '5px 10px' }} onClick={onEdit}>
Bearbeiten
</button>
)}
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.85rem', padding: '5px 10px' }}
onClick={onClose}
aria-label="Schließen"
>
</button>
</div>
</div>
{/* Body */}
{isLegalHold ? (
<p style={{ color: 'var(--danger)', fontSize: '0.9rem' }}>
Dieses Medium ist gesperrt und steht nicht zur Verfügung.
</p>
) : media?.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={media.embed_url} target="_blank" rel="noreferrer">{media.embed_url}</a>
</p>
) : isHeic ? (
<div>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>
HEIC/HEIF in diesem Browser oft keine eingebettete Vorschau. Zum Ansehen herunterladen oder im neuen Tab öffnen.
</p>
{fileUrl && (
<a className="btn btn-secondary" href={fileUrl} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
)}
</div>
) : isImage ? (
<img
key={media?.id}
alt=""
src={fileUrl}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain', display: 'block' }}
/>
) : isVideo ? (
<video
key={media?.id}
src={fileUrl}
controls
playsInline
preload="metadata"
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
>
Wiedergabe nicht unterstützt.
</video>
) : isPdf ? (
<div>
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>PDF zur Ansicht im neuen Tab öffnen.</p>
{fileUrl && (
<a className="btn btn-secondary" href={fileUrl} target="_blank" rel="noopener noreferrer">
PDF öffnen
</a>
)}
</div>
) : fileUrl ? (
<p style={{ fontSize: '14px' }}>
<a href={fileUrl} target="_blank" rel="noreferrer">Datei öffnen</a>
</p>
) : (
<p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>Keine Vorschau verfügbar.</p>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,200 @@
/**
* Wiederverwendbares Melde-Modal (P-13).
* Props:
* targetType 'media_asset' | 'exercise'
* targetId ID des Zielobjekts
* targetLabel Anzeigename für den Header (z.B. Dateiname oder Übungstitel)
* onClose Callback zum Schließen
*/
import React, { useState } from 'react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
const REASON_OPTIONS = [
{ value: '', label: '— Grund auswählen —' },
{ value: 'copyright', label: 'Urheberrechtsverletzung' },
{ value: 'image_rights', label: 'Bildrechtsverletzung / Recht am eigenen Bild' },
{ value: 'privacy', label: 'Datenschutz / Persönlichkeitsrecht' },
{ value: 'minors', label: 'Darstellung Minderjähriger' },
{ value: 'illegal_content', label: 'Rechtswidriger Inhalt' },
{ value: 'youth_protection', label: 'Jugendschutz' },
{ value: 'offensive_content', label: 'Beleidigender / anstößiger Inhalt' },
{ value: 'other', label: 'Sonstiges' },
]
export default function ReportContentModal({ targetType, targetId, targetLabel, onClose, onSuccess }) {
const { user } = useAuth()
const [reason, setReason] = useState('')
const [description, setDescription] = useState('')
const [name, setName] = useState(user?.name || '')
const [email, setEmail] = useState(user?.email || '')
const [confirmed, setConfirmed] = useState(false)
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [success, setSuccess] = useState(false)
async function handleSubmit(e) {
e.preventDefault()
if (!reason) { setError('Bitte einen Meldegrund auswählen.'); return }
if (description.trim().length < 10) { setError('Beschreibung muss mindestens 10 Zeichen haben.'); return }
if (!name.trim()) { setError('Bitte deinen Namen eingeben.'); return }
if (!email.trim()) { setError('Bitte deine E-Mail-Adresse eingeben.'); return }
if (!confirmed) { setError('Bitte bestätige die Gutglaubenserklärung.'); return }
setSaving(true)
setError(null)
try {
await api.submitContentReport({
target_type: targetType,
target_id: targetId,
report_reason: reason,
report_description: description.trim(),
reporter_name: name.trim(),
reporter_email: email.trim(),
good_faith_confirmed: true,
})
setSuccess(true)
if (onSuccess) onSuccess()
} catch (err) {
setError(err.message || String(err))
} finally {
setSaving(false)
}
}
return (
<div
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
zIndex: 1200,
padding: '1rem',
overflowY: 'auto',
}}
onClick={(e) => { if (e.target === e.currentTarget) onClose() }}
>
<div
style={{
background: 'var(--surface)',
borderRadius: '12px',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
marginTop: '2rem',
marginBottom: '2rem',
}}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>Inhalt melden</h2>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}></button>
</div>
{targetLabel && (
<p className="muted" style={{ fontSize: '0.85rem', marginBottom: '1rem', marginTop: 0 }}>
{targetType === 'media_asset' ? 'Medium' : 'Übung'}: <strong>{targetLabel}</strong>
</p>
)}
{success ? (
<div>
<p style={{ color: 'var(--accent)', fontWeight: 600 }}>Meldung eingegangen.</p>
<p className="muted" style={{ fontSize: '0.9rem' }}>
Deine Meldung wurde gespeichert und wird von unseren Moderatoren geprüft. Vielen Dank.
</p>
<button type="button" className="btn btn-primary btn-full" onClick={onClose}>
Schließen
</button>
</div>
) : (
<form onSubmit={handleSubmit}>
<div className="form-row">
<label className="form-label">Meldegrund *</label>
<select
className="form-input"
value={reason}
onChange={(e) => setReason(e.target.value)}
required
>
{REASON_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
<div className="form-row">
<label className="form-label">Beschreibung * (mind. 10 Zeichen)</label>
<textarea
className="form-input"
rows={3}
value={description}
onChange={(e) => setDescription(e.target.value)}
placeholder="Bitte erläutere kurz, warum du diesen Inhalt meldest."
required
/>
</div>
<div className="form-row">
<label className="form-label">Dein Name *</label>
<input
type="text"
className="form-input"
value={name}
onChange={user?.name ? undefined : (e) => setName(e.target.value)}
readOnly={!!user?.name}
style={user?.name ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
required
/>
</div>
<div className="form-row">
<label className="form-label">Deine E-Mail-Adresse *</label>
<input
type="email"
className="form-input"
value={email}
onChange={user?.email ? undefined : (e) => setEmail(e.target.value)}
readOnly={!!user?.email}
style={user?.email ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
required
/>
</div>
<div className="form-row">
<label style={{ display: 'flex', gap: '0.6rem', alignItems: 'flex-start', cursor: 'pointer' }}>
<input
type="checkbox"
checked={confirmed}
onChange={(e) => setConfirmed(e.target.checked)}
style={{ marginTop: '3px', flexShrink: 0 }}
/>
<span style={{ fontSize: '0.85rem', color: 'var(--text2)' }}>
Ich melde diesen Inhalt nach bestem Wissen und Gewissen. Ich bestätige, dass meine Meldung
nicht missbräuchlich ist und ich der Überzeugung bin, dass der gemeldete Inhalt rechtswidrig
ist oder gegen die Nutzungsbedingungen verstößt.
</span>
</label>
</div>
{error && (
<p style={{ color: 'var(--danger)', fontSize: '0.88rem', margin: '0.5rem 0' }}>{error}</p>
)}
<div style={{ display: 'flex', gap: '0.5rem', marginTop: '1rem' }}>
<button type="submit" className="btn btn-primary" style={{ flex: 1 }} disabled={saving}>
{saving ? 'Wird gesendet…' : 'Meldung absenden'}
</button>
<button type="button" className="btn btn-secondary" onClick={onClose} disabled={saving}>
Abbrechen
</button>
</div>
</form>
)}
</div>
</div>
)
}

View File

@ -0,0 +1,318 @@
/**
* P-06: Einwilligungsdialog vor Medien-Uploads und Sichtbarkeits-Promotionen.
* VORLÄUFIG Texte noch nicht juristisch geprüft (p06-v1-conservative).
*/
import React, { useState } from 'react'
const INITIAL = {
copyright_notice: '',
rights_holder_confirmed: false,
contains_identifiable_persons: null,
person_consent_confirmed: false,
person_consent_context: '',
contains_minors: null,
parental_consent_confirmed: false,
parental_consent_context: '',
contains_music: null,
music_rights_confirmed: false,
music_rights_context: '',
contains_third_party_content: null,
third_party_rights_confirmed: false,
third_party_rights_context: '',
}
/**
* @param {object} props
* @param {boolean} props.open
* @param {function} props.onCancel
* @param {function(decl: object): void} props.onConfirm
* @param {string} [props.targetVisibility] - 'private'|'club'|'official'
* @param {boolean} [props.isPromotion] - true wenn Promotion (nicht Erstupload)
* @param {string} [props.mode] - 'upload'|'promotion'|'redeclaration'
*/
export default function RightsDeclarationDialog({
open,
onCancel,
onConfirm,
targetVisibility = 'private',
isPromotion = false,
mode = 'upload',
}) {
const [decl, setDecl] = useState({ ...INITIAL })
const [error, setError] = useState('')
if (!open) return null
const setField = (key, val) => setDecl((d) => ({ ...d, [key]: val }))
const validate = () => {
if (!decl.rights_holder_confirmed)
return 'Bitte bestätigen, dass du die erforderlichen Rechte an diesem Medium besitzt.'
if (decl.contains_identifiable_persons === null)
return 'Bitte angeben, ob erkennbare Personen abgebildet sind.'
if (decl.contains_identifiable_persons && !decl.person_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen aller erkennbaren Personen vorliegen.'
if (decl.contains_minors === null)
return 'Bitte angeben, ob Minderjährige abgebildet sind.'
if (decl.contains_minors && !decl.parental_consent_confirmed)
return 'Bitte bestätigen, dass die Einwilligungen der Sorgeberechtigten vorliegen.'
if (decl.contains_music === null)
return 'Bitte angeben, ob das Medium Musik enthält.'
if (decl.contains_music && !decl.music_rights_confirmed)
return 'Bitte bestätigen, dass die erforderlichen Musikrechte vorliegen.'
if (decl.contains_third_party_content === null)
return 'Bitte angeben, ob fremde geschützte Inhalte enthalten sind.'
if (decl.contains_third_party_content && !decl.third_party_rights_confirmed)
return 'Bitte bestätigen, dass die Rechte an allen enthaltenen Fremdmaterialien vorliegen.'
return ''
}
const handleConfirm = () => {
const err = validate()
if (err) { setError(err); return }
setError('')
onConfirm({ ...decl })
setDecl({ ...INITIAL })
}
const handleCancel = () => {
setDecl({ ...INITIAL })
setError('')
onCancel()
}
const visLabel = { private: 'privat', club: 'Verein', official: 'offiziell' }[targetVisibility] || targetVisibility
const titleMap = {
upload: 'Rechte-Erklärung Upload',
promotion: `Rechte-Erklärung Freigabe für „${visLabel}"`,
redeclaration: 'Rechte-Erklärung Nachdeklaration',
}
return (
<div className="admin-modal-backdrop" role="presentation" onClick={(e) => e.target === e.currentTarget && handleCancel()}>
<div
className="admin-modal-sheet"
role="dialog"
aria-modal="true"
aria-labelledby="rights-decl-title"
style={{ maxWidth: '520px', width: '100%', maxHeight: '90vh', display: 'flex', flexDirection: 'column' }}
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="rights-decl-title" className="admin-modal-sheet__title">
{titleMap[mode] || titleMap.upload}
</h3>
<button type="button" className="admin-modal-sheet__close" onClick={handleCancel} aria-label="Schließen">
×
</button>
</div>
<div style={{ overflowY: 'auto', flex: 1, padding: '14px 16px' }}>
<p style={{ fontSize: '0.82rem', color: 'var(--text3)', marginBottom: 14 }}>
VORLÄUFIG Texte noch nicht juristisch geprüft (p06-v1-conservative).
{isPromotion && (
<> Die bestehende Erklärung gilt nicht für die Sichtbarkeit {visLabel}". Bitte erneut bestätigen.</>
)}
</p>
{/* Copyright */}
<div style={{ marginBottom: 14 }}>
<label htmlFor="rdlg-cr" style={{ display: 'block', fontSize: '0.85rem', fontWeight: 600, marginBottom: 4 }}>
Copyright-Angabe (optional)
</label>
<input
id="rdlg-cr"
type="text"
className="form-input"
placeholder="z.B. © 2026 Lars Stommer, CC BY-NC"
value={decl.copyright_notice}
onChange={(e) => setField('copyright_notice', e.target.value)}
style={{ fontSize: '0.9rem' }}
/>
</div>
{/* T1: Rechteinhaber */}
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 14 }}>
<input
type="checkbox"
id="rdlg-rhc"
checked={decl.rights_holder_confirmed}
onChange={(e) => setField('rights_holder_confirmed', e.target.checked)}
style={{ marginTop: 3, flexShrink: 0 }}
/>
<label htmlFor="rdlg-rhc" style={{ fontSize: '0.9rem' }}>
Ich bestätige, dass ich die erforderlichen Urheber- und Nutzungsrechte an diesem Medium besitze
oder rechtmäßig zur Veröffentlichung berechtigt bin. *
</label>
</div>
{/* T2 / T3: Personen */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Erkennbare Personen abgebildet? *</legend>
<div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cip" checked={decl.contains_identifiable_persons === true}
onChange={() => setField('contains_identifiable_persons', true)} /> Ja
</label>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cip" checked={decl.contains_identifiable_persons === false}
onChange={() => setField('contains_identifiable_persons', false)} /> Nein
</label>
</div>
{decl.contains_identifiable_persons === true && (
<div style={{ paddingLeft: 4 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
<input type="checkbox" id="rdlg-pcc" checked={decl.person_consent_confirmed}
onChange={(e) => setField('person_consent_confirmed', e.target.checked)}
style={{ marginTop: 3, flexShrink: 0 }} />
<label htmlFor="rdlg-pcc" style={{ fontSize: '0.9rem' }}>
Einwilligungen aller erkennbaren Personen liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Einwilligungskontext (optional)
</label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Schriftliche Einwilligung vom 2026-05-01, im Archiv abgelegt"
value={decl.person_consent_context}
onChange={(e) => setField('person_consent_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div>
)}
</fieldset>
{/* T4 / T5: Minderjährige */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Minderjährige abgebildet? *</legend>
<div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cm" checked={decl.contains_minors === true}
onChange={() => setField('contains_minors', true)} /> Ja
</label>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cm" checked={decl.contains_minors === false}
onChange={() => setField('contains_minors', false)} /> Nein
</label>
</div>
{decl.contains_minors === true && (
<div style={{ paddingLeft: 4 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
<input type="checkbox" id="rdlg-pcc2" checked={decl.parental_consent_confirmed}
onChange={(e) => setField('parental_consent_confirmed', e.target.checked)}
style={{ marginTop: 3, flexShrink: 0 }} />
<label htmlFor="rdlg-pcc2" style={{ fontSize: '0.9rem' }}>
Einwilligungen der Sorgeberechtigten liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Einwilligungskontext (optional)
</label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Elterliche Einwilligung per E-Mail vom 2026-04-15 erhalten"
value={decl.parental_consent_context}
onChange={(e) => setField('parental_consent_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div>
)}
</fieldset>
{/* T6 / T7: Musik */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 14 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Musik enthalten? *</legend>
<div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cmu" checked={decl.contains_music === true}
onChange={() => setField('contains_music', true)} /> Ja
</label>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-cmu" checked={decl.contains_music === false}
onChange={() => setField('contains_music', false)} /> Nein
</label>
</div>
{decl.contains_music === true && (
<div style={{ paddingLeft: 4 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
<input type="checkbox" id="rdlg-mrc" checked={decl.music_rights_confirmed}
onChange={(e) => setField('music_rights_confirmed', e.target.checked)}
style={{ marginTop: 3, flexShrink: 0 }} />
<label htmlFor="rdlg-mrc" style={{ fontSize: '0.9rem' }}>
Musikrechte (GEMA / Lizenz) liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Lizenz / GEMA-Kontext (optional)
</label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Creative Commons BY 4.0, Lizenz-Nr. oder GEMA-Freimeldung"
value={decl.music_rights_context}
onChange={(e) => setField('music_rights_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div>
)}
</fieldset>
{/* T8 / T9: Fremdinhalte */}
<fieldset style={{ border: 'none', padding: 0, marginBottom: 4 }}>
<legend style={{ fontSize: '0.9rem', fontWeight: 600, marginBottom: 6 }}>Fremde geschützte Inhalte (Logos, Grafiken, Fotos Dritter)? *</legend>
<div style={{ display: 'flex', gap: 16, marginBottom: 6 }}>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-ctpc" checked={decl.contains_third_party_content === true}
onChange={() => setField('contains_third_party_content', true)} /> Ja
</label>
<label style={{ fontSize: '0.9rem' }}>
<input type="radio" name="rdlg-ctpc" checked={decl.contains_third_party_content === false}
onChange={() => setField('contains_third_party_content', false)} /> Nein
</label>
</div>
{decl.contains_third_party_content === true && (
<div style={{ paddingLeft: 4 }}>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: 10, marginBottom: 6 }}>
<input type="checkbox" id="rdlg-tprc" checked={decl.third_party_rights_confirmed}
onChange={(e) => setField('third_party_rights_confirmed', e.target.checked)}
style={{ marginTop: 3, flexShrink: 0 }} />
<label htmlFor="rdlg-tprc" style={{ fontSize: '0.9rem' }}>
Rechte an allen enthaltenen Fremdmaterialien liegen vor. *
</label>
</div>
<label style={{ display: 'block', fontSize: '0.82rem', color: 'var(--text2)', marginBottom: 3 }}>
Rechtsgrundlage (optional)
</label>
<textarea
className="form-input"
rows={2}
placeholder="z.B. Verbandslogo mit Genehmigung vom 2026-03-10, Lizenz-Nr. …"
value={decl.third_party_rights_context}
onChange={(e) => setField('third_party_rights_context', e.target.value)}
style={{ fontSize: '0.85rem', resize: 'vertical' }}
/>
</div>
)}
</fieldset>
{error && (
<p style={{ color: 'var(--danger)', fontSize: '0.85rem', marginTop: 12 }}>{error}</p>
)}
</div>
<div style={{ padding: '12px 16px', borderTop: '1px solid var(--border)', flexShrink: 0, display: 'flex', gap: 10, justifyContent: 'flex-end' }}>
<button className="btn btn-secondary" type="button" onClick={handleCancel}>
Abbrechen
</button>
<button className="btn btn-primary" type="button" onClick={handleConfirm}>
Bestätigen &amp; hochladen
</button>
</div>
</div>
</div>
)
}

View File

@ -118,6 +118,11 @@ export function AuthProvider({ children }) {
setUser(null)
localStorage.removeItem('authToken')
localStorage.removeItem(ACTIVE_CLUB_STORAGE_KEY)
for (const key of Object.keys(sessionStorage)) {
if (key.startsWith('sj_coach_')) {
sessionStorage.removeItem(key)
}
}
}
const value = {

View File

@ -17,6 +17,11 @@ export function canAccessOrgInbox(user) {
return activeClubMemberships(user.clubs).some((c) => (c.roles || []).includes('club_admin'))
}
function canSeeContentReports(user) {
if (user?.role === 'admin' || user?.role === 'superadmin') return true
return activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin'))
}
/** Nach Annahme/Ablehnung: Posteingang-Badges & Widget aktualisieren */
export function notifyOrgInboxChanged() {
window.dispatchEvent(new Event('shinkan:inbox-changed'))
@ -24,44 +29,77 @@ export function notifyOrgInboxChanged() {
export function OrgInboxProvider({ user, children }) {
const [items, setItems] = useState([])
const [contentReports, setContentReports] = useState([])
const [contentReportsError, setContentReportsError] = useState(null)
const canAccess = useMemo(() => canAccessOrgInbox(user), [user])
const canAccessReports = useMemo(() => canSeeContentReports(user), [user])
const refresh = useCallback(async () => {
if (!canAccess) {
setItems([])
return
} else {
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
}
}
try {
const data = await api.getInboxJoinRequests()
setItems(Array.isArray(data) ? data : [])
} catch {
setItems([])
if (!canAccessReports) {
setContentReports([])
setContentReportsError(null)
} else {
try {
const data = await api.getInboxContentReports()
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
} catch (err) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}, [canAccess])
}, [canAccess, canAccessReports])
useEffect(() => {
if (!canAccess) {
if (!canAccess && !canAccessReports) {
setItems([])
setContentReports([])
setContentReportsError(null)
return undefined
}
let cancelled = false
;(async () => {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
if (canAccess) {
try {
const data = await api.getInboxJoinRequests()
if (!cancelled) setItems(Array.isArray(data) ? data : [])
} catch {
if (!cancelled) setItems([])
}
}
if (canAccessReports) {
try {
const data = await api.getInboxContentReports()
if (!cancelled) {
setContentReports(Array.isArray(data) ? data : [])
setContentReportsError(null)
}
} catch (err) {
if (!cancelled) {
setContentReports([])
setContentReportsError(err?.message || String(err))
}
}
}
})()
return () => {
cancelled = true
}
}, [canAccess, user?.id])
}, [canAccess, canAccessReports, user?.id])
useEffect(() => {
const onChange = () => {
refresh()
}
const onChange = () => { refresh() }
window.addEventListener('shinkan:inbox-changed', onChange)
return () => window.removeEventListener('shinkan:inbox-changed', onChange)
}, [refresh])
@ -70,10 +108,17 @@ export function OrgInboxProvider({ user, children }) {
() => ({
inboxJoinRequests: items,
inboxCount: items.length,
contentReports,
contentReportCount: contentReports.filter((r) => r.status === 'submitted').length,
contentReportsError,
refreshOrgInbox: refresh,
canAccessOrgInbox: canAccess,
canAccessContentReports: canAccessReports,
isSuperadmin: user?.role === 'superadmin',
isPlatformAdmin: user?.role === 'admin' || user?.role === 'superadmin',
isClubAdmin: activeClubMemberships(user?.clubs || []).some((c) => (c.roles || []).includes('club_admin')),
}),
[items, refresh, canAccess]
[items, contentReports, contentReportsError, refresh, canAccess, canAccessReports, user?.role, user?.clubs]
)
return <OrgInboxContext.Provider value={value}>{children}</OrgInboxContext.Provider>

View File

@ -400,7 +400,7 @@ function AccountSettingsPage() {
value={newPw1}
onChange={(e) => setNewPw1(e.target.value)}
autoComplete="new-password"
minLength={4}
minLength={8}
/>
</div>
<div className="form-row" style={{ marginBottom: '0.75rem' }}>
@ -426,6 +426,10 @@ function AccountSettingsPage() {
<Link to="/settings/system">Technische Systeminformationen</Link>
{' — App-Version, Build, Umgebung, Datenbankschema'}
</p>
<p className="muted" style={{ marginTop: '0.6rem', fontSize: '0.875rem', lineHeight: 1.5 }}>
<Link to="/settings/legal">Rechtliches</Link>
{' — Impressum, Datenschutz, Nutzungsbedingungen, Medienrichtlinie'}
</p>
</div>
)
}

View File

@ -0,0 +1,567 @@
import { useState, useEffect, useCallback } from 'react'
import { jsPDF } from 'jspdf'
import { FileText, Plus, Edit2, Archive, CheckCircle, Clock, Copy, Download, ChevronUp, ChevronDown } from 'lucide-react'
import api from '../utils/api'
// PDF generation
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22
const marginR = 22
const marginTop = 28
const pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277 // A4 297mm - 20mm bottom margin
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) {
pdf.addPage()
y = marginTop
}
}
// Title
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
// Meta line
const STATUS_DE = { published: 'Gültig seit', draft: 'Entwurf — Stand', archived: 'Archiviert — Stand' }
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | ${STATUS_DE[doc.status] || doc.status} ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
// Sections
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
// Footer on every page
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
pdf.setTextColor(0, 0, 0)
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// Sub-components
const DOC_TYPES = [
{ key: 'impressum', label: 'Impressum', defaultTitle: 'Impressum' },
{ key: 'privacy_policy', label: 'Datenschutz', defaultTitle: 'Datenschutzerklärung' },
{ key: 'terms_of_use', label: 'Nutzungsbedingungen', defaultTitle: 'Nutzungsbedingungen' },
{ key: 'media_policy', label: 'Medienrichtlinie', defaultTitle: 'Medienrichtlinie' },
]
const STATUS_LABELS = {
draft: { label: 'Entwurf', color: 'var(--text3)' },
published: { label: 'Veröffentlicht', color: 'var(--accent)' },
archived: { label: 'Archiviert', color: 'var(--danger)' },
}
function StatusBadge({ status }) {
const s = STATUS_LABELS[status] || { label: status, color: 'var(--text3)' }
return (
<span style={{
fontSize: '0.75rem', fontWeight: 600, color: s.color,
border: `1px solid ${s.color}`, borderRadius: '4px', padding: '1px 6px',
}}>
{s.label}
</span>
)
}
function SectionEditor({ sections, onChange }) {
const update = (i, field, val) =>
onChange(sections.map((s, idx) => idx === i ? { ...s, [field]: val } : s))
const remove = (i) => onChange(sections.filter((_, idx) => idx !== i))
const move = (i, dir) => {
const next = [...sections]
const j = i + dir
if (j < 0 || j >= next.length) return
;[next[i], next[j]] = [next[j], next[i]]
onChange(next)
}
// Insert empty section after index i (-1 = prepend)
const insertAfter = (i) => {
const next = [...sections]
next.splice(i + 1, 0, { heading: '', content: '' })
onChange(next)
}
const InsertButton = ({ afterIndex }) => (
<div style={{ display: 'flex', justifyContent: 'center', margin: '4px 0' }}>
<button
type="button"
onClick={() => insertAfter(afterIndex)}
title="Neuen Abschnitt hier einfügen"
style={{
background: 'none', border: '1px dashed var(--border)', borderRadius: '6px',
color: 'var(--text3)', cursor: 'pointer', fontSize: '0.78rem',
padding: '2px 16px', lineHeight: 1.8,
}}
>
+ Abschnitt hier einfügen
</button>
</div>
)
return (
<div>
<InsertButton afterIndex={-1} />
{sections.map((sec, i) => (
<div key={i}>
<div className="card" style={{ padding: '12px', marginBottom: 0 }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '0.5rem' }}>
<span style={{ fontSize: '0.8rem', color: 'var(--text3)', fontWeight: 600 }}>Abschnitt {i + 1}</span>
<div style={{ display: 'flex', gap: '4px' }}>
<button
type="button" onClick={() => move(i, -1)} disabled={i === 0}
title="Nach oben" className="btn btn-secondary"
style={{ padding: '2px 6px', opacity: i === 0 ? 0.3 : 1 }}
>
<ChevronUp size={13} />
</button>
<button
type="button" onClick={() => move(i, 1)} disabled={i === sections.length - 1}
title="Nach unten" className="btn btn-secondary"
style={{ padding: '2px 6px', opacity: i === sections.length - 1 ? 0.3 : 1 }}
>
<ChevronDown size={13} />
</button>
<button
type="button" onClick={() => remove(i)}
className="btn btn-secondary" style={{ fontSize: '0.75rem', padding: '2px 8px' }}
>
Entfernen
</button>
</div>
</div>
<div className="form-row">
<label className="form-label">Überschrift</label>
<input
type="text" className="form-input" value={sec.heading}
onChange={e => update(i, 'heading', e.target.value)}
placeholder="Abschnittsüberschrift"
/>
</div>
<div className="form-row">
<label className="form-label">Inhalt</label>
<textarea
className="form-input" rows={4} value={sec.content}
onChange={e => update(i, 'content', e.target.value)}
placeholder="Textinhalt des Abschnitts"
/>
</div>
</div>
<InsertButton afterIndex={i} />
</div>
))}
{sections.length === 0 && (
<p style={{ color: 'var(--text3)', fontSize: '0.88rem', textAlign: 'center', margin: '0.5rem 0' }}>
Noch keine Abschnitte. Klicke auf + Abschnitt hier einfügen".
</p>
)}
</div>
)
}
function DocumentRow({ doc, onPublish, onArchive, onEdit, onCopy, onDownload, onViewAudit }) {
const [downloading, setDownloading] = useState(false)
const handleDownload = async () => {
setDownloading(true)
try { await onDownload(doc) } finally { setDownloading(false) }
}
return (
<div className="card" style={{ display: 'flex', alignItems: 'center', gap: '1rem', padding: '12px 16px', marginBottom: '0.5rem' }}>
<div style={{ flex: 1 }}>
<div style={{ fontWeight: 600, marginBottom: '2px' }}>
{doc.title}{' '}
<span style={{ color: 'var(--text3)', fontWeight: 400, fontSize: '0.85rem' }}>v{doc.version}</span>
</div>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<StatusBadge status={doc.status} />
{doc.published_at && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>
veröffentlicht {new Date(doc.published_at).toLocaleDateString('de-DE')}
</span>
)}
{doc.change_note && (
<span style={{ fontSize: '0.78rem', color: 'var(--text3)', fontStyle: 'italic' }}>
{doc.change_note}
</span>
)}
</div>
</div>
<div style={{ display: 'flex', gap: '0.4rem', flexShrink: 0 }}>
{doc.status === 'draft' && (
<>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onEdit(doc)} title="Bearbeiten">
<Edit2 size={13} />
</button>
<button className="btn btn-primary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onPublish(doc)} title="Veröffentlichen">
<CheckCircle size={13} />
</button>
</>
)}
{doc.status === 'published' && (
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onArchive(doc)} title="Archivieren">
<Archive size={13} />
</button>
)}
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onCopy(doc)} title="Als neuen Entwurf kopieren">
<Copy size={13} />
</button>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={handleDownload} disabled={downloading} title="Als PDF herunterladen">
{downloading ? '…' : <Download size={13} />}
</button>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.78rem' }}
onClick={() => onViewAudit(doc)} title="Änderungslog">
<Clock size={13} />
</button>
</div>
</div>
)
}
function EditForm({ docType, editDoc, onSaved, onCancel }) {
const [title, setTitle] = useState(editDoc ? editDoc.title : docType.defaultTitle)
const [sections, setSections] = useState([])
const [changeNote, setChangeNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [loadingDoc, setLoadingDoc] = useState(!!editDoc)
useEffect(() => {
if (editDoc) {
api.getLegalDocument(editDoc.id)
.then(d => {
setTitle(d.title)
setSections(d.content_sections || [])
setChangeNote(d.change_note || '')
})
.catch(e => setError(e.message))
.finally(() => setLoadingDoc(false))
}
}, [editDoc])
const handleSave = async () => {
setSaving(true)
setError(null)
try {
const payload = { title, content_sections: sections, change_note: changeNote || null }
if (editDoc) {
await api.updateLegalDocument(editDoc.id, payload)
} else {
await api.createLegalDocument({ document_type: docType.key, ...payload })
}
onSaved()
} catch (e) {
setError(e.message)
} finally {
setSaving(false)
}
}
if (loadingDoc) return <div className="spinner" />
return (
<div className="card" style={{ marginTop: '1rem' }}>
<h3 style={{ marginBottom: '1rem' }}>
{editDoc ? 'Entwurf bearbeiten' : 'Neuen Entwurf erstellen'}
</h3>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
)}
<div className="form-row">
<label className="form-label">Titel *</label>
<input type="text" className="form-input" value={title}
onChange={e => setTitle(e.target.value)} required />
</div>
<div className="form-row">
<label className="form-label">Abschnitte</label>
<SectionEditor sections={sections} onChange={setSections} />
</div>
<div className="form-row">
<label className="form-label">Änderungsnotiz (optional)</label>
<input type="text" className="form-input" value={changeNote}
onChange={e => setChangeNote(e.target.value)}
placeholder="z. B. Erste Version nach juristischer Prüfung" />
</div>
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.5rem' }}>
<button className="btn btn-primary" onClick={handleSave} disabled={saving || !title.trim()}>
{saving ? 'Speichern…' : 'Entwurf speichern'}
</button>
<button className="btn btn-secondary" onClick={onCancel}>Abbrechen</button>
</div>
</div>
)
}
function AuditLog({ docId, onClose }) {
const [entries, setEntries] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
api.getLegalDocumentAudit(docId)
.then(setEntries)
.catch(() => setEntries([]))
.finally(() => setLoading(false))
}, [docId])
const ACTION_LABELS = { created: 'Erstellt', updated: 'Bearbeitet', published: 'Veröffentlicht', archived: 'Archiviert' }
return (
<div className="card" style={{ marginTop: '1rem' }}>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '1rem' }}>
<h3 style={{ margin: 0 }}>Änderungslog</h3>
<button className="btn btn-secondary" style={{ padding: '4px 10px', fontSize: '0.8rem' }} onClick={onClose}>
Schließen
</button>
</div>
{loading ? <div className="spinner" /> : entries.length === 0 ? (
<p style={{ color: 'var(--text3)' }}>Keine Einträge.</p>
) : entries.map(e => (
<div key={e.id} style={{ padding: '8px 0', borderBottom: '1px solid var(--border)' }}>
<div style={{ display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
<strong style={{ fontSize: '0.85rem' }}>{ACTION_LABELS[e.action] || e.action}</strong>
{e.previous_status && <span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.previous_status}</span>}
<span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>{new Date(e.created_at).toLocaleString('de-DE')}</span>
{e.changed_by_name && <span style={{ fontSize: '0.78rem', color: 'var(--text3)' }}>von {e.changed_by_name}</span>}
</div>
{e.change_note && (
<p style={{ margin: '2px 0 0', fontSize: '0.82rem', color: 'var(--text2)', fontStyle: 'italic' }}>
{e.change_note}
</p>
)}
</div>
))}
</div>
)
}
// Main page
export default function AdminLegalDocumentsPage() {
const [activeType, setActiveType] = useState(DOC_TYPES[0].key)
const [documents, setDocuments] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState(null)
const [showForm, setShowForm] = useState(false)
const [editDoc, setEditDoc] = useState(null)
const [auditDocId, setAuditDocId] = useState(null)
const [confirmPublish, setConfirmPublish] = useState(null)
const activeDocType = DOC_TYPES.find(d => d.key === activeType)
const load = useCallback(() => {
setLoading(true)
setError(null)
api.listLegalDocuments(activeType)
.then(setDocuments)
.catch(e => setError(e.message))
.finally(() => setLoading(false))
}, [activeType])
useEffect(() => {
load()
setShowForm(false)
setEditDoc(null)
setAuditDocId(null)
setConfirmPublish(null)
}, [load])
const handlePublish = (doc) => setConfirmPublish(doc)
const confirmAndPublish = async () => {
if (!confirmPublish) return
try {
await api.publishLegalDocument(confirmPublish.id, null)
setConfirmPublish(null)
load()
} catch (e) { alert('Fehler: ' + e.message) }
}
const handleArchive = async (doc) => {
if (!confirm(`"${doc.title}" archivieren?`)) return
try { await api.archiveLegalDocument(doc.id); load() }
catch (e) { alert('Fehler: ' + e.message) }
}
const handleEdit = (doc) => { setEditDoc(doc); setShowForm(true); setAuditDocId(null) }
const handleViewAudit = (doc) => { setAuditDocId(doc.id); setShowForm(false); setEditDoc(null) }
const handleNew = () => { setEditDoc(null); setShowForm(true); setAuditDocId(null) }
const handleSaved = () => { setShowForm(false); setEditDoc(null); load() }
const handleCopy = async (doc) => {
try { await api.copyLegalDocumentAsDraft(doc.id); load() }
catch (e) { alert('Fehler: ' + e.message) }
}
const handleDownload = async (doc) => {
const full = await api.getLegalDocument(doc.id)
generateLegalPdf(full)
}
const publishedDoc = documents.find(d => d.status === 'published')
return (
<div className="app-main">
<div style={{ maxWidth: '860px', margin: '0 auto' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '0.75rem', marginBottom: '1.5rem' }}>
<FileText size={22} color="var(--accent)" />
<h1 style={{ margin: 0 }}>Rechtstexte verwalten</h1>
</div>
<div className="card" style={{ marginBottom: '1rem', padding: '12px', background: 'var(--surface)' }}>
<p style={{ margin: 0, fontSize: '0.88rem', color: 'var(--text2)' }}>
Versionierte Rechtstexte erstellen, bearbeiten und veröffentlichen.
Pro Dokumententyp kann immer nur ein Text veröffentlicht sein.
</p>
</div>
{/* Tab-Leiste */}
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap', marginBottom: '1.25rem' }}>
{DOC_TYPES.map(dt => (
<button
key={dt.key}
className={`btn ${activeType === dt.key ? 'btn-primary' : 'btn-secondary'}`}
onClick={() => setActiveType(dt.key)}
style={{ minWidth: '130px' }}
>
{dt.label}
</button>
))}
</div>
{/* Aktuell veröffentlicht */}
{publishedDoc && (
<div className="card" style={{ marginBottom: '1rem', borderLeft: '3px solid var(--accent)', display: 'flex', alignItems: 'center', gap: '0.75rem', padding: '10px 16px' }}>
<CheckCircle size={16} color="var(--accent)" />
<span style={{ fontSize: '0.88rem' }}>
<strong>Aktuell live:</strong> {publishedDoc.title} (v{publishedDoc.version}
{publishedDoc.published_at && `, ${new Date(publishedDoc.published_at).toLocaleDateString('de-DE')}`})
</span>
</div>
)}
{/* Aktionen */}
<div style={{ display: 'flex', justifyContent: 'flex-end', marginBottom: '0.75rem' }}>
<button className="btn btn-primary" onClick={handleNew} style={{ display: 'flex', alignItems: 'center', gap: '0.4rem' }}>
<Plus size={15} /> Neuer Entwurf
</button>
</div>
{error && (
<div className="card" style={{ borderLeft: '4px solid var(--danger)', marginBottom: '1rem' }}>
<span style={{ color: 'var(--danger)' }}>{error}</span>
</div>
)}
{/* Bestätigung Veröffentlichen */}
{confirmPublish && (
<div className="card" style={{ borderLeft: '4px solid var(--accent)', marginBottom: '1rem' }}>
<p style={{ margin: '0 0 0.75rem' }}>
<strong>{confirmPublish.title}"</strong> veröffentlichen?
{publishedDoc && publishedDoc.id !== confirmPublish.id && (
<> Die aktuelle Version ({publishedDoc.title} v{publishedDoc.version}) wird dabei archiviert.</>
)}
</p>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<button className="btn btn-primary" onClick={confirmAndPublish}>Ja, veröffentlichen</button>
<button className="btn btn-secondary" onClick={() => setConfirmPublish(null)}>Abbrechen</button>
</div>
</div>
)}
{/* Dokumentenliste */}
{loading ? <div className="spinner" /> : documents.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem', color: 'var(--text3)' }}>
Noch keine Versionen für {activeDocType?.label}. Erstelle einen neuen Entwurf.
</div>
) : (
documents.map(doc => (
<DocumentRow
key={doc.id}
doc={doc}
onPublish={handlePublish}
onArchive={handleArchive}
onEdit={handleEdit}
onCopy={handleCopy}
onDownload={handleDownload}
onViewAudit={handleViewAudit}
/>
))
)}
{showForm && (
<EditForm
docType={activeDocType}
editDoc={editDoc}
onSaved={handleSaved}
onCancel={() => { setShowForm(false); setEditDoc(null) }}
/>
)}
{auditDocId && (
<AuditLog docId={auditDocId} onClose={() => setAuditDocId(null)} />
)}
</div>
</div>
)
}

View File

@ -5,6 +5,8 @@ import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import ExerciseMediaThumbTile from '../components/ExerciseMediaThumbTile'
import MediaPreviewModal from '../components/MediaPreviewModal'
import ReportContentModal from '../components/ReportContentModal'
import {
SHINKAN_EXERCISE_MEDIA_DRAG_MIME,
buildExerciseMediaDragPayload,
@ -391,6 +393,7 @@ function ExerciseFormPage() {
const [archiveItems, setArchiveItems] = useState([])
const [archiveError, setArchiveError] = useState(null)
const [mediaPreview, setMediaPreview] = useState(null)
const [reportTarget, setReportTarget] = useState(null)
useEffect(() => {
const next = {}
@ -434,7 +437,7 @@ function ExerciseFormPage() {
q: archiveQ.trim() || undefined,
limit: 40,
})
if (!cancelled) setArchiveItems(res.items || [])
if (!cancelled) setArchiveItems((res.items || []).filter(a => !a.legal_hold_active))
} catch (e) {
if (!cancelled) setArchiveError(e.message || String(e))
} finally {
@ -1652,62 +1655,28 @@ function ExerciseFormPage() {
</div>
)}
{mediaPreview && (
<div
role="dialog"
aria-modal="true"
aria-label="Medienvorschau"
style={{
position: 'fixed',
inset: 0,
background: 'rgba(0,0,0,0.55)',
zIndex: 1001,
overflow: 'auto',
padding: '16px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
onClick={() => setMediaPreview(null)}
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(null)}
>
<div
className="card"
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
onClick={(e) => e.stopPropagation()}
>
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
{mediaPreview.embed_url ? (
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
<a href={mediaPreview.embed_url} target="_blank" rel="noreferrer">
{mediaPreview.embed_url}
</a>
</p>
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
<video
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
controls
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
/>
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
<img
alt=""
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
/>
) : (
<p style={{ fontSize: '14px' }}>
<a href={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)} target="_blank" rel="noreferrer">
Datei öffnen
</a>
</p>
)}
<div style={{ marginTop: '16px' }}>
<button type="button" className="btn btn-secondary" onClick={() => setMediaPreview(null)}>
Schließen
</button>
</div>
</div>
</div>
<MediaPreviewModal
title={(mediaPreview.title || '').trim() || mediaPreview.original_filename || `Medium #${mediaPreview.id}`}
media={mediaPreview}
fileUrl={mediaPreview.embed_url ? null : resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
onClose={() => setMediaPreview(null)}
onReport={
!mediaPreview.asset_legal_hold_active
? () => {
setReportTarget(mediaPreview)
setMediaPreview(null)
}
: null
}
/>
)}
{reportTarget && (
<ReportContentModal
targetType="media_asset"
targetId={reportTarget.media_asset_id || reportTarget.id}
targetLabel={reportTarget.title || reportTarget.original_filename || `Medium #${reportTarget.id}`}
onClose={() => setReportTarget(null)}
/>
)}
</div>
)}

View File

@ -10,6 +10,33 @@ const CLUB_ROLE_OPTIONS = [
{ 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) {
if (!iso) return ''
const s = String(iso)
@ -19,13 +46,300 @@ function formatWhen(iso) {
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>
)
}
const WORKFLOW_STEPS = [
{ key: 'submitted', label: 'Eingegangen' },
{ key: 'under_review', label: 'In Bearbeitung' },
{ key: 'closed', label: 'Abgeschlossen' },
]
function WorkflowBar({ status }) {
const closed = ['resolved_no_action', 'resolved_legal_hold', 'rejected_invalid'].includes(status)
const step = closed ? 2 : status === 'under_review' ? 1 : 0
return (
<div style={{ display: 'flex', alignItems: 'center', gap: 0, marginBottom: '1.25rem' }}>
{WORKFLOW_STEPS.map((s, i) => {
const active = i === step
const done = i < step
return (
<React.Fragment key={s.key}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', minWidth: 0, flex: 1 }}>
<div style={{
width: 28, height: 28, borderRadius: '50%',
background: active ? 'var(--accent)' : done ? 'var(--accent-dark)' : 'var(--surface2)',
border: active ? '2px solid var(--accent)' : done ? '2px solid var(--accent-dark)' : '2px solid var(--border)',
display: 'flex', alignItems: 'center', justifyContent: 'center',
color: (active || done) ? '#fff' : 'var(--text3)',
fontSize: '0.75rem', fontWeight: 700,
}}>
{done ? '✓' : i + 1}
</div>
<span style={{ fontSize: '0.7rem', marginTop: 4, color: active ? 'var(--accent)' : 'var(--text3)', fontWeight: active ? 600 : 400, whiteSpace: 'nowrap' }}>
{s.label}
</span>
</div>
{i < WORKFLOW_STEPS.length - 1 && (
<div style={{ flex: 1, height: 2, background: i < step ? 'var(--accent-dark)' : 'var(--border)', marginBottom: 18 }} />
)}
</React.Fragment>
)
})}
</div>
)
}
function ReportDetailModal({ report, onClose, onRefresh, isSuperadmin, isClubAdmin }) {
const [resolutionNote, setResolutionNote] = useState(report.resolution_note || '')
const [legalHoldNote, setLegalHoldNote] = useState('')
const [saving, setSaving] = useState(false)
const [error, setError] = useState(null)
const [showLegalHoldForm, setShowLegalHoldForm] = useState(false)
const isClosed = ['resolved_no_action', 'resolved_legal_hold', 'rejected_invalid'].includes(report.status)
const isOpen = !isClosed
async function patchAndClose(body) {
setSaving(true)
setError(null)
try {
await api.patchContentReport(report.id, body)
notifyOrgInboxChanged()
onRefresh()
onClose()
} catch (err) {
setError(err.message || String(err))
setSaving(false)
}
}
async function handleStatus(status) {
if ((status === 'resolved_no_action' || status === 'rejected_invalid') && !resolutionNote.trim()) {
setError('Bitte eine Begründung eingeben.')
return
}
await patchAndClose({ status, resolution_note: resolutionNote.trim() || undefined })
}
async function handleSaveNote() {
if (!resolutionNote.trim()) { setError('Notiz ist leer.'); return }
await patchAndClose({ resolution_note: resolutionNote.trim() })
}
async function handleLegalHold() {
if (!legalHoldNote.trim()) { setError('Begründung für Legal Hold erforderlich.'); return }
await patchAndClose({ legalHoldNote })
}
async function handleLegalHoldSubmit() {
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))
setSaving(false)
}
}
const targetLabel = report.target_filename || report.target_exercise_name
? `${report.target_filename || report.target_exercise_name} (#${report.target_id})`
: `#${report.target_id}`
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: '580px', width: '100%', marginTop: '2rem', marginBottom: '2rem',
}}>
{/* Header */}
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '1.25rem' }}>
<div>
<h2 style={{ margin: 0, fontSize: '1.1rem' }}>
Meldung #{report.id}
<PriorityBadge priority={report.priority} />
</h2>
<div style={{ marginTop: 4, fontSize: '0.82rem', color: STATUS_COLORS[report.status], fontWeight: 600 }}>
{STATUS_LABELS[report.status] || report.status}
</div>
</div>
<button type="button" className="btn btn-secondary" style={{ padding: '4px 10px' }} onClick={onClose}></button>
</div>
{/* Workflow Bar */}
<WorkflowBar status={report.status} />
{/* Details */}
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.55rem', marginBottom: '1.25rem', fontSize: '0.9rem' }}>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Ziel</span>
<span>{report.target_type === 'media_asset' ? 'Medium' : 'Übung'} {targetLabel}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Meldegrund</span>
<span>{REASON_LABELS[report.report_reason] || report.report_reason}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Beschreibung</span>
<span style={{ whiteSpace: 'pre-wrap', flex: 1 }}>{report.report_description}</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Meldende Person</span>
<span>
{report.reporter_name}
{report.reporter_email ? ` · ${report.reporter_email}` : ''}
{report.reporter_profile_id ? ` · Profil #${report.reporter_profile_id}` : ' · anonym'}
</span>
</div>
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Eingegangen</span>
<span>{formatWhen(report.submitted_at || report.created_at)}</span>
</div>
{report.reviewed_by_name && (
<div style={{ display: 'flex', gap: '0.5rem' }}>
<span className="muted" style={{ minWidth: 110 }}>Geprüft von</span>
<span>{report.reviewed_by_name}{report.reviewed_at ? ` · ${formatWhen(report.reviewed_at)}` : ''}</span>
</div>
)}
{report.target_legal_hold_active && (
<div style={{ background: 'rgba(216,90,48,0.1)', borderRadius: '8px', padding: '0.5rem 0.75rem' }}>
<span style={{ color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>Legal Hold aktiv auf diesem Medium</span>
</div>
)}
</div>
{/* Resolution Note (Bearbeitungskommentar) */}
<div className="form-row" style={{ marginBottom: '1rem' }}>
<label className="form-label">
Bearbeitungskommentar
{(report.status === 'resolved_no_action' || report.status === 'rejected_invalid') && ' *'}
</label>
<textarea
className="form-input"
rows={3}
value={resolutionNote}
onChange={(e) => setResolutionNote(e.target.value)}
placeholder="Dokumentation der Entscheidung, interne Notizen…"
readOnly={isClosed}
style={isClosed ? { background: 'var(--surface2)', color: 'var(--text2)' } : undefined}
/>
{!isClosed && resolutionNote.trim() && resolutionNote.trim() !== (report.resolution_note || '').trim() && (
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.4rem', fontSize: '0.82rem' }} onClick={handleSaveNote} disabled={saving}>
Kommentar speichern
</button>
)}
</div>
{error && (
<div style={{ color: 'var(--danger)', marginBottom: '0.75rem', fontSize: '0.9rem' }}>{error}</div>
)}
{/* Actions */}
{isOpen && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.6rem' }}>
{report.status === 'submitted' && (
<button type="button" className="btn btn-primary" onClick={() => patchAndClose({ status: 'under_review' })} disabled={saving}>
In Bearbeitung nehmen
</button>
)}
<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 || (isClubAdmin && report.target_visibility !== 'official')) && !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 {isSuperadmin ? '(Superadmin)' : '(Vereinsadmin)'}
</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…" />
</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={handleLegalHoldSubmit} disabled={saving}>
Legal Hold bestätigen
</button>
<button type="button" className="btn btn-secondary" onClick={() => setShowLegalHoldForm(false)}>Abbrechen</button>
</div>
</div>
)}
</div>
)}
{isClosed && (
<button
type="button" className="btn btn-secondary"
style={{ width: '100%' }}
onClick={() => patchAndClose({ status: 'submitted' })}
disabled={saving}
>
Meldung wieder öffnen (zurück auf Eingegangen)
</button>
)}
</div>
</div>
)
}
export default function InboxPage() {
const { canAccessOrgInbox, refreshOrgInbox, inboxJoinRequests } = useOrgInbox()
const {
canAccessOrgInbox,
canAccessContentReports,
isSuperadmin,
isPlatformAdmin,
isClubAdmin,
refreshOrgInbox,
inboxJoinRequests,
contentReports,
contentReportCount,
contentReportsError,
} = useOrgInbox()
const [loading, setLoading] = useState(true)
const [acceptModal, setAcceptModal] = useState(null)
const [reportModal, setReportModal] = useState(null)
const [showArchive, setShowArchive] = useState(false)
const load = useCallback(async () => {
if (!canAccessOrgInbox) {
if (!canAccessOrgInbox && !canAccessContentReports) {
setLoading(false)
return
}
@ -35,13 +349,13 @@ export default function InboxPage() {
} finally {
setLoading(false)
}
}, [canAccessOrgInbox, refreshOrgInbox])
}, [canAccessOrgInbox, canAccessContentReports, refreshOrgInbox])
useEffect(() => {
load()
}, [load])
if (!canAccessOrgInbox) {
if (!canAccessOrgInbox && !canAccessContentReports) {
return (
<div className="app-page">
<h1 className="page-title">Posteingang</h1>
@ -61,7 +375,7 @@ export default function InboxPage() {
Posteingang
</h1>
<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>
</div>
<button type="button" className="btn btn-secondary" onClick={() => load()} disabled={loading}>
@ -73,68 +387,196 @@ export default function InboxPage() {
<div style={{ padding: '2rem', textAlign: 'center' }}>
<div className="spinner" />
</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) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<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}
<>
{/* Abschnitt 1: Beitrittsanträge */}
{canAccessOrgInbox && (
<section style={{ marginBottom: '2rem' }}>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Beitrittsanträge
{inboxJoinRequests.length > 0 && (
<span
style={{
background: 'var(--accent)',
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>
<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 className="inbox-page__list">
{inboxJoinRequests.map((req) => (
<div key={`${req.club_id}-${req.id}`} className="card inbox-request-card">
<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>
{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))
}
)}
</section>
)}
{/* Abschnitt 2: Inhaltsmeldungen */}
{canAccessContentReports && (() => {
const openReports = contentReports.filter((r) => r.status === 'submitted' || r.status === 'under_review')
const archivedReports = contentReports.filter((r) => r.status !== 'submitted' && r.status !== 'under_review')
function ReportCard({ rep }) {
return (
<div
key={rep.id}
className="card"
style={{
padding: '1rem 1.25rem',
cursor: 'pointer',
borderLeft: rep.priority === 'high' ? '3px solid var(--danger)' : '3px solid transparent',
opacity: rep.status !== 'submitted' && rep.status !== 'under_review' ? 0.75 : 1,
}}
onClick={() => setReportModal(rep)}
>
Ablehnen
</button>
</div>
</div>
))}
</div>
<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_filename || rep.target_exercise_name ? ` ${rep.target_filename || rep.target_exercise_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>
)
}
return (
<section>
<h2 style={{ fontSize: '1rem', fontWeight: 600, marginBottom: '0.75rem', color: 'var(--text2)' }}>
Inhaltsmeldungen
{contentReportCount > 0 && (
<span style={{ background: 'var(--danger)', color: '#fff', borderRadius: '12px', padding: '1px 8px', fontSize: '0.75rem', marginLeft: '0.5rem' }}>
{contentReportCount} neu
</span>
)}
</h2>
{contentReportsError ? (
<div className="card" style={{ padding: '1.25rem', borderLeft: '3px solid var(--danger)' }}>
<p style={{ margin: 0, color: 'var(--danger)', fontWeight: 600, fontSize: '0.88rem' }}>
Fehler beim Laden: {contentReportsError}
</p>
<button type="button" className="btn btn-secondary" style={{ marginTop: '0.5rem', fontSize: '0.82rem' }} onClick={load}>
Erneut versuchen
</button>
</div>
) : (
<>
{openReports.length === 0 ? (
<div className="card" style={{ padding: '1.25rem', marginBottom: '0.75rem' }}>
<p style={{ margin: 0 }} className="muted">Keine offenen Inhaltsmeldungen.</p>
</div>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem', marginBottom: '0.75rem' }}>
{openReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
{archivedReports.length > 0 && (
<div>
<button
type="button"
style={{
background: 'none', border: 'none', cursor: 'pointer', padding: '0.4rem 0',
color: 'var(--text2)', fontSize: '0.88rem', display: 'flex', alignItems: 'center', gap: '0.35rem',
}}
onClick={() => setShowArchive((v) => !v)}
>
<span style={{ fontSize: '0.75rem' }}>{showArchive ? '▼' : '▶'}</span>
Archiv ({archivedReports.length} abgeschlossene Meldung{archivedReports.length !== 1 ? 'en' : ''})
</button>
{showArchive && (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', marginTop: '0.5rem' }}>
{archivedReports.map((rep) => <ReportCard key={rep.id} rep={rep} />)}
</div>
)}
</div>
)}
</>
)}
</section>
)
})()}
</>
)}
{acceptModal && (
@ -217,6 +659,16 @@ export default function InboxPage() {
</div>
</div>
)}
{reportModal && (
<ReportDetailModal
report={reportModal}
isSuperadmin={isSuperadmin}
isClubAdmin={isClubAdmin && !isPlatformAdmin}
onClose={() => setReportModal(null)}
onRefresh={load}
/>
)}
</div>
)
}

View File

@ -0,0 +1,333 @@
import { useState, useEffect } from 'react'
import { Link } from 'react-router-dom'
import { jsPDF } from 'jspdf'
import api from '../utils/api'
function generateLegalPdf(doc) {
const pdf = new jsPDF({ format: 'a4', unit: 'mm' })
const marginL = 22, marginR = 22, marginTop = 28, pageW = 210
const contentW = pageW - marginL - marginR
const bottomLimit = 277
let y = marginTop
const checkBreak = (need) => {
if (y + need > bottomLimit) { pdf.addPage(); y = marginTop }
}
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(20)
pdf.text(doc.title, marginL, y)
y += 10
const dateStr = doc.published_at
? new Date(doc.published_at).toLocaleDateString('de-DE')
: new Date(doc.updated_at || doc.created_at).toLocaleDateString('de-DE')
const metaLine = `Version ${doc.version} | Gueltig seit ${dateStr}`
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
pdf.setTextColor(90, 90, 90)
pdf.text(metaLine, marginL, y)
y += 3
pdf.setDrawColor(0, 0, 0)
pdf.setLineWidth(0.4)
pdf.line(marginL, y, pageW - marginR, y)
y += 8
pdf.setTextColor(0, 0, 0)
for (const section of (doc.content_sections || [])) {
checkBreak(14)
pdf.setFont('helvetica', 'bold')
pdf.setFontSize(11)
pdf.text(section.heading || '', marginL, y)
y += 6
if (section.content) {
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(10)
const lines = pdf.splitTextToSize(section.content, contentW)
for (const line of lines) {
checkBreak(5)
pdf.text(line, marginL, y)
y += 5
}
}
y += 5
}
const total = pdf.getNumberOfPages()
for (let i = 1; i <= total; i++) {
pdf.setPage(i)
pdf.setFont('helvetica', 'normal')
pdf.setFontSize(8)
pdf.setTextColor(150, 150, 150)
const fy = 289
pdf.text(
`Shinkan Jinkendo | Exportiert am ${new Date().toLocaleDateString('de-DE')}`,
marginL, fy
)
pdf.text(`Seite ${i} von ${total}`, pageW - marginR, fy, { align: 'right' })
}
pdf.save(`${doc.document_type}_v${doc.version}.pdf`)
}
// document_type values used in the DB / API
const TYPE_MAP = {
impressum: 'impressum',
datenschutz: 'privacy_policy',
nutzungsbedingungen: 'terms_of_use',
medienrichtlinie: 'media_policy',
}
const PAGES = {
impressum: {
title: 'Impressum',
sections: [
{
heading: 'Betreiber / Verantwortlicher',
placeholder: '[Name und Rechtsform des Betreibers — vom Betreiber einzutragen]',
},
{
heading: 'Anschrift',
placeholder: '[Straße, Hausnummer, PLZ, Ort — vom Betreiber einzutragen]',
},
{
heading: 'Vertretungsberechtigte Person',
placeholder: '[Name der vertretungsberechtigten Person — vom Betreiber einzutragen]',
},
{
heading: 'Kontakt',
placeholder: '[E-Mail-Adresse, ggf. Telefonnummer — vom Betreiber einzutragen]',
},
{
heading: 'Registerangaben (falls relevant)',
placeholder: '[Vereinsregister, Handelsregister o. ä. — vom Rechtsanwalt prüfen lassen]',
},
],
},
datenschutz: {
title: 'Datenschutzerklärung',
sections: [
{
heading: 'Verantwortlicher',
placeholder: '[Name, Anschrift und Kontakt des Verantwortlichen — vom Betreiber einzutragen]',
},
{
heading: 'Zwecke der Verarbeitung',
placeholder: '[Welche Daten werden zu welchem Zweck verarbeitet? — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Rechtsgrundlagen',
placeholder: '[Art. 6 DSGVO: Einwilligung, Vertrag, berechtigtes Interesse — vom Rechtsanwalt zu bestimmen]',
},
{
heading: 'Empfänger und Dienstleister',
placeholder: '[SMTP-Anbieter, Hosting, ggf. weitere — vom Rechtsanwalt und Betreiber zu listen]',
},
{
heading: 'Speicherdauern',
placeholder: '[Wie lange werden welche Daten gespeichert? — vom Rechtsanwalt zu bestimmen]',
},
{
heading: 'Betroffenenrechte',
placeholder: '[Auskunft, Berichtigung, Löschung, Widerspruch, Datenübertragbarkeit — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Browser-Speicher (localStorage, sessionStorage)',
placeholder: '[Technisch notwendige Speicherung des Auth-Tokens und Sitzungsdaten. Nach TDDDG §25 ggf. ohne Einwilligung zulässig — vom Rechtsanwalt zu prüfen]',
},
{
heading: 'Kontakt für Datenschutzanfragen',
placeholder: '[E-Mail oder Kontaktweg für Datenschutzanfragen — vom Betreiber einzutragen]',
},
{
heading: 'Zuständige Aufsichtsbehörde',
placeholder: '[Zuständige Datenschutzbehörde je nach Bundesland — vom Rechtsanwalt zu bestimmen]',
},
],
},
nutzungsbedingungen: {
title: 'Nutzungsbedingungen',
sections: [
{
heading: 'Nutzungsumfang',
placeholder: '[Für welche Nutzergruppen und Zwecke darf die Plattform genutzt werden? — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Registrierung',
placeholder: '[Voraussetzungen für die Registrierung, Wahrheitspflicht der Angaben — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Zulässige und unzulässige Inhalte',
placeholder: '[Was darf hochgeladen und veröffentlicht werden? Was ist verboten? — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Verantwortlichkeit der Nutzer',
placeholder: '[Nutzer sind für ihre hochgeladenen Inhalte selbst verantwortlich — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Sperrung und Löschung',
placeholder: '[Unter welchen Bedingungen können Konten oder Inhalte gesperrt oder gelöscht werden? — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Haftungshinweise',
placeholder: '[Haftungsausschluss für Nutzerinhalte, externe Links, Systemausfälle — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Geltungsbereich und anwendbares Recht',
placeholder: '[Welches Recht ist anwendbar? Welcher Gerichtsstand gilt? — vom Rechtsanwalt zu bestimmen]',
},
],
},
medienrichtlinie: {
title: 'Medienrichtlinie',
sections: [
{
heading: 'Urheberrechte',
placeholder: '[Nur eigene oder ausdrücklich lizenzierte Inhalte hochladen — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Rechte am eigenen Bild (§ 22 KUG)',
placeholder: '[Erkennbare Personen müssen eingewilligt haben; besondere Regeln für Minderjährige — juristisch zu prüfen und zu formulieren]',
},
{
heading: 'Minderjährige',
placeholder: '[Besondere Schutzpflichten bei Aufnahmen von Minderjährigen — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Musik und sonstige Fremdinhalte',
placeholder: '[Keine Hintergrundmusik oder andere Fremdinhalte ohne gültige Lizenz — vom Rechtsanwalt zu formulieren]',
},
{
heading: 'Sichtbarkeitsstufen',
placeholder: '[Erläuterung der Stufen: privat, vereinsintern, öffentlich — vom Betreiber zu beschreiben]',
},
{
heading: 'Meldewege für rechtsverletzende Inhalte',
placeholder: '[Wie können Inhalte gemeldet werden? — wird nach Umsetzung von P-13 (Content-Melde-Backend) ergänzt]',
},
{
heading: 'Lösch- und Sperrlogik',
placeholder: '[Wann und wie werden gemeldete Inhalte entfernt oder gesperrt? — wird nach Umsetzung von P-11 und P-13 ergänzt]',
},
],
},
}
const LEGAL_LINKS = [
{ to: '/impressum', label: 'Impressum' },
{ to: '/datenschutz', label: 'Datenschutz' },
{ to: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ to: '/medienrichtlinie', label: 'Medienrichtlinie' },
]
function LegalPage({ type }) {
const fallback = PAGES[type]
const [apiDoc, setApiDoc] = useState(undefined) // undefined = loading, null = not found
const [loading, setLoading] = useState(true)
const documentType = TYPE_MAP[type]
useEffect(() => {
if (!documentType) {
setLoading(false)
return
}
api.getPublishedLegalDocument(documentType)
.then(doc => setApiDoc(doc))
.catch(() => setApiDoc(null))
.finally(() => setLoading(false))
}, [documentType])
if (!fallback) return null
const isPlaceholder = !apiDoc
const title = apiDoc ? apiDoc.title : fallback.title
const sections = apiDoc
? (apiDoc.content_sections || [])
: fallback.sections.map(s => ({ heading: s.heading, content: s.placeholder }))
return (
<div style={{ minHeight: '100vh', background: 'var(--bg)', padding: '2rem 1rem' }}>
<div style={{ maxWidth: '720px', margin: '0 auto' }}>
<div style={{ marginBottom: '1.5rem' }}>
<Link to="/login" style={{ color: 'var(--accent)', textDecoration: 'none', fontSize: '0.9rem' }}>
Zurück zur Anmeldung
</Link>
</div>
{loading ? (
<div className="spinner" />
) : (
<>
{isPlaceholder && (
<div
className="card"
style={{
marginBottom: '1.5rem',
borderLeft: '4px solid var(--danger)',
background: 'var(--surface)',
}}
>
<strong style={{ color: 'var(--danger)' }}> MUSTER / PLATZHALTER</strong>
<p style={{ margin: '0.5rem 0 0', color: 'var(--text2)', fontSize: '0.9rem' }}>
Inhalt wird vor Produktivbetrieb juristisch geprüft und durch den Betreiber ergänzt.
Diese Seite hat keinen rechtlich verbindlichen Charakter.
</p>
</div>
)}
<div style={{ display: 'flex', alignItems: 'baseline', gap: '1rem', marginBottom: '2rem', flexWrap: 'wrap' }}>
<h1 style={{ margin: 0, color: 'var(--text1)' }}>{title}</h1>
{apiDoc && (
<button
onClick={() => generateLegalPdf(apiDoc)}
className="btn btn-secondary"
style={{ fontSize: '0.82rem', padding: '4px 12px', flexShrink: 0 }}
>
PDF herunterladen
</button>
)}
</div>
{sections.map((section, i) => (
<div key={i} style={{ marginBottom: '1.75rem' }}>
<h2 style={{ fontSize: '1.05rem', marginBottom: '0.4rem', color: 'var(--text1)' }}>
{section.heading}
</h2>
<p style={{ color: isPlaceholder ? 'var(--text3)' : 'var(--text1)', fontStyle: isPlaceholder ? 'italic' : 'normal', margin: 0, whiteSpace: 'pre-wrap' }}>
{section.content}
</p>
</div>
))}
</>
)}
<div
style={{
marginTop: '3rem',
paddingTop: '1rem',
borderTop: '1px solid var(--border)',
fontSize: '0.82rem',
color: 'var(--text3)',
textAlign: 'center',
display: 'flex',
gap: '1.25rem',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
{LEGAL_LINKS.map((l) => (
<Link key={l.to} to={l.to} style={{ color: 'var(--text3)' }}>
{l.label}
</Link>
))}
</div>
</div>
</div>
)
}
export default LegalPage

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect } from 'react'
import { useNavigate } from 'react-router-dom'
import { useNavigate, Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@ -172,7 +172,7 @@ function LoginPage() {
onChange={(e) => setPassword(e.target.value)}
required
placeholder="••••••••"
minLength="6"
minLength="8"
/>
</div>
@ -238,9 +238,25 @@ function LoginPage() {
</p>
</div>
)}
<p style={{ textAlign: 'center', color: 'var(--text3)', marginTop: '1.5rem', fontSize: '0.875rem' }}>
v0.1.0 Development
</p>
<div
style={{
marginTop: '1.5rem',
paddingTop: '1rem',
borderTop: '1px solid var(--border)',
fontSize: '0.78rem',
color: 'var(--text3)',
textAlign: 'center',
display: 'flex',
gap: '1rem',
justifyContent: 'center',
flexWrap: 'wrap',
}}
>
<Link to="/impressum" style={{ color: 'var(--text3)' }}>Impressum</Link>
<Link to="/datenschutz" style={{ color: 'var(--text3)' }}>Datenschutz</Link>
<Link to="/nutzungsbedingungen" style={{ color: 'var(--text3)' }}>Nutzungsbedingungen</Link>
<Link to="/medienrichtlinie" style={{ color: 'var(--text3)' }}>Medienrichtlinie</Link>
</div>
</div>
</div>
)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,66 @@
import { Link } from 'react-router-dom'
import { Scale } from 'lucide-react'
const LEGAL_LINKS = [
{ to: '/impressum', label: 'Impressum', description: 'Angaben zum Betreiber und Verantwortlichen' },
{ to: '/datenschutz', label: 'Datenschutzerklärung', description: 'Verarbeitung personenbezogener Daten' },
{ to: '/nutzungsbedingungen', label: 'Nutzungsbedingungen', description: 'Regeln für die Nutzung der Plattform' },
{ to: '/medienrichtlinie', label: 'Medienrichtlinie', description: 'Urheberrecht, Rechte am eigenen Bild, Sichtbarkeit' },
]
function SettingsLegalPage() {
return (
<div className="page-padding app-page" style={{ padding: '1rem' }}>
<p style={{ marginBottom: '0.75rem' }}>
<Link to="/settings" style={{ fontSize: '0.9rem' }}>
Zurück zu Einstellungen
</Link>
</p>
<h1 style={{ marginBottom: '0.35rem', fontSize: '1.5rem' }}>Rechtliches</h1>
<p
style={{
color: 'var(--text2)',
marginBottom: '1.25rem',
fontSize: '0.95rem',
lineHeight: 1.5,
maxWidth: '40rem',
}}
>
Rechtstexte und Richtlinien der Plattform.
Die Inhalte befinden sich noch in redaktioneller Prüfung.
</p>
<div className="card" style={{ padding: 0, overflow: 'hidden' }}>
{LEGAL_LINKS.map((item, idx) => (
<Link
key={item.to}
to={item.to}
style={{
display: 'flex',
alignItems: 'center',
gap: '0.85rem',
padding: '1rem 1.1rem',
borderBottom: idx < LEGAL_LINKS.length - 1 ? '1px solid var(--border)' : 'none',
textDecoration: 'none',
color: 'inherit',
}}
>
<Scale size={18} style={{ color: 'var(--text3)', flexShrink: 0 }} />
<div>
<div style={{ fontWeight: 500, color: 'var(--text1)', fontSize: '0.95rem' }}>
{item.label}
</div>
<div style={{ fontSize: '0.8rem', color: 'var(--text3)', marginTop: '0.15rem' }}>
{item.description}
</div>
</div>
<span style={{ marginLeft: 'auto', color: 'var(--text3)', fontSize: '1.1rem' }}></span>
</Link>
))}
</div>
</div>
)
}
export default SettingsLegalPage

View File

@ -400,7 +400,7 @@ export default function TrainingCoachPage() {
return () => {
cancelled = true
}
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.item_type])
}, [step, currentEntry?.item?.exercise_id, currentEntry?.item?.exercise_variant_id, currentEntry?.item?.item_type])
const handleSaveDebrief = async () => {
setSaveOk(null)
@ -739,6 +739,7 @@ export default function TrainingCoachPage() {
error={catalogError}
exercise={catalogExercise}
exerciseId={currentEntry?.item?.exercise_id ?? null}
variantId={currentEntry?.item?.exercise_variant_id ?? null}
/>
</div>
</>

View File

@ -33,7 +33,7 @@ export default function TrainingUnitRunPage() {
const [loadError, setLoadError] = useState(null)
const [loading, setLoading] = useState(true)
const [checked, setChecked] = useState(() => new Set())
const [peekExerciseId, setPeekExerciseId] = useState(null)
const [peekCtx, setPeekCtx] = useState(null)
const loadChecked = useCallback((uid) => {
try {
@ -143,9 +143,10 @@ export default function TrainingUnitRunPage() {
return (
<div className="training-run-page app-page" style={{ paddingBottom: '2rem' }}>
<ExercisePeekModal
open={peekExerciseId != null}
exerciseId={peekExerciseId}
onClose={() => setPeekExerciseId(null)}
open={peekCtx != null}
exerciseId={peekCtx?.exerciseId}
variantId={peekCtx?.variantId ?? undefined}
onClose={() => setPeekCtx(null)}
/>
<nav className="training-run-toolbar no-print" style={{ marginBottom: '1rem', display: 'flex', gap: '0.5rem', flexWrap: 'wrap', alignItems: 'center' }}>
@ -314,7 +315,12 @@ export default function TrainingUnitRunPage() {
type="button"
className="btn btn-secondary"
style={{ fontSize: '0.82rem', margin: 0 }}
onClick={() => setPeekExerciseId(it.exercise_id)}
onClick={() =>
setPeekCtx({
exerciseId: it.exercise_id,
variantId: it.exercise_variant_id != null ? Number(it.exercise_variant_id) : null,
})
}
>
Katalog (Popup)
</button>

View File

@ -615,6 +615,27 @@ export async function bulkUploadMediaAssets(files, options = {}) {
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
// Copyright + P-06: Rechte-Erklaerung + Kontextfelder
if (options.copyright_notice != null && String(options.copyright_notice).trim())
formData.append('copyright_notice', String(options.copyright_notice).trim())
const p06Fields = [
'rights_holder_confirmed',
'contains_identifiable_persons',
'person_consent_confirmed',
'person_consent_context',
'contains_minors',
'parental_consent_confirmed',
'parental_consent_context',
'contains_music',
'music_rights_confirmed',
'music_rights_context',
'contains_third_party_content',
'third_party_rights_confirmed',
'third_party_rights_context',
]
for (const f of p06Fields) {
if (options[f] != null && options[f] !== '') formData.append(f, String(options[f]))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
@ -644,6 +665,17 @@ export async function bulkUploadMediaAssets(files, options = {}) {
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function getMediaAssetJournal(assetId) {
return request(`/api/admin/media-rights/assets/${assetId}/journal`)
}
export async function addMediaAssetDeclarationCorrection(assetId, body) {
return request(`/api/admin/media-rights/assets/${assetId}/correction`, {
method: 'POST',
body: JSON.stringify(body),
})
}
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
method: 'POST',
@ -651,6 +683,25 @@ export async function attachExerciseMediaFromAsset(exerciseId, body) {
})
}
// P-11: Legal-Hold-Endpunkte
export async function setMediaAssetLegalHold(assetId, reasonCode, reasonNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold`, {
method: 'POST',
body: JSON.stringify({ reason_code: reasonCode, reason_note: reasonNote }),
})
}
export async function releaseMediaAssetLegalHold(assetId, releaseNote) {
return request(`/api/admin/media-assets/${assetId}/legal-hold/release`, {
method: 'POST',
body: JSON.stringify({ release_note: releaseNote }),
})
}
export async function listMediaAssetsWithLegalHold(limit = 100, offset = 0) {
return request(`/api/admin/media-assets/legal-hold?limit=${limit}&offset=${offset}`)
}
export async function getExercise(id) {
return request(`/api/exercises/${id}`)
}
@ -1395,7 +1446,12 @@ export const api = {
bulkMediaLifecycle,
bulkPatchMediaAssets,
bulkUploadMediaAssets,
getMediaAssetJournal,
addMediaAssetDeclarationCorrection,
attachExerciseMediaFromAsset,
setMediaAssetLegalHold,
releaseMediaAssetLegalHold,
listMediaAssetsWithLegalHold,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,
createExerciseProgressionGraph,
@ -1510,7 +1566,44 @@ export const api = {
request('/api/import/mediawiki/logs'),
deleteMediaWikiImportReference: (refId) =>
request(`/api/import/mediawiki/references/${refId}`, { method: 'DELETE' })
request(`/api/import/mediawiki/references/${refId}`, { method: 'DELETE' }),
// Legal Documents (public)
getPublishedLegalDocument: (documentType) =>
request(`/api/legal-documents/${documentType}/published`),
// Legal Documents (superadmin)
listLegalDocuments: (documentType) =>
request(`/api/admin/legal-documents${documentType ? `?document_type=${documentType}` : ''}`),
createLegalDocument: (data) =>
request('/api/admin/legal-documents', { method: 'POST', body: JSON.stringify(data) }),
getLegalDocument: (id) =>
request(`/api/admin/legal-documents/${id}`),
updateLegalDocument: (id, data) =>
request(`/api/admin/legal-documents/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
publishLegalDocument: (id, changeNote) =>
request(`/api/admin/legal-documents/${id}/publish`, {
method: 'POST',
body: JSON.stringify({ change_note: changeNote }),
}),
archiveLegalDocument: (id) =>
request(`/api/admin/legal-documents/${id}/archive`, { method: 'POST' }),
copyLegalDocumentAsDraft: (id) =>
request(`/api/admin/legal-documents/${id}/copy-as-draft`, { method: 'POST' }),
getLegalDocumentAudit: (id) =>
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

View File

@ -1,13 +1,16 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.40"
export const BUILD_DATE = "2026-05-06"
export const APP_VERSION = "0.8.94"
export const BUILD_DATE = "2026-05-11"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
LoginPage: "1.0.2",
SettingsLegalPage: "1.0.0",
AdminLegalDocumentsPage: "1.3.0",
LegalPage: "1.3.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
ExercisesPage: "1.5.0", // Fokus +/- Regeln, nur ohne Fokusbereich; Filterprefs
AccountSettingsPage: "1.0.1",
ExercisesPage: "1.5.0",
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0",
@ -15,6 +18,17 @@ export const PAGE_VERSIONS = {
TrainingFrameworkProgramEditPage: "1.5.0",
TrainingUnitRunPage: "1.1.0",
TrainingCoachPage: "1.0.0",
AdminCatalogsPage: "2.2.0", // Updated: Frontend API Calls & Field Names für renamed tables
TrainerContextsPage: "1.0.0", // New: Trainer-Kontext-Verwaltung
AdminCatalogsPage: "2.2.0",
TrainerContextsPage: "1.0.0",
MediaLibraryPage: "1.9.0", // P-13: open_report_count Badge + Journal content_report_filed
ExerciseFormPage: "1.1.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
ExerciseInlineFileMediaModal: "1.1.0", // P-06: RightsDeclarationDialog vor Upload
ExerciseInlineEmbedModal: "1.1.0", // P-06: RightsDeclarationDialog vor Embed-Upload
ExerciseMediaEmbed: "1.1.0", // P-11: Legal-Hold-Placeholder statt Datei
ExerciseMediaThumbTile: "1.1.0", // P-11: Legal-Hold-Kachel statt Datei-Vorschau
ExerciseAttachmentMediaStrip: "1.2.0", // P-13: MediaPreviewModal (geteilt) + Melden im Viewer
InboxPage: "2.3.0", // P-13: Archiv-Trennung offen/abgeschlossen; Club-Admin Legal-Hold-Button
OrgInboxContext: "1.3.0", // P-13: isClubAdmin + isPlatformAdmin exposed
MediaPreviewModal: "1.0.0", // P-13: geteilter Medienvorschau-Dialog (Melden + Bearbeiten optional)
ReportContentModal: "1.2.0", // P-13: onSuccess callback fuer Badge-Update
}

View File

@ -0,0 +1,11 @@
{
"folders": [
{
"path": "../mitai-jinkendo"
},
{
"path": "."
}
],
"settings": {}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 37 KiB

BIN
screenshots/03-uebungen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 51 KiB

BIN
screenshots/04-vereine.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

View File

@ -1,4 +1,4 @@
{
"status": "failed",
"status": "passed",
"failedTests": []
}

View File

@ -18,6 +18,8 @@ async function login(page) {
await page.fill('input[type="email"]', TEST_EMAIL);
await page.fill('input[type="password"]', TEST_PASSWORD);
await submitLoginForm(page);
// Wait until auth is complete: URL leaves /login and Dashboard is rendered
await page.waitForURL((url) => !url.toString().includes('/login'), { timeout: 15000 });
await page.waitForLoadState('networkidle');
}
@ -141,6 +143,320 @@ test('7. Session-Persistenz nach Reload', async ({ page }) => {
console.log('✓ Session bleibt nach Reload erhalten');
});
test('P-12: sessionStorage wird bei Logout bereinigt (sj_coach_* Schlüssel)', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await page.waitForLoadState('networkidle');
// Coach-Sitzungsdaten simulieren (wie sie TrainingCoachPage schreibt)
await page.evaluate(() => {
sessionStorage.setItem('sj_coach_step_42', '3');
sessionStorage.setItem('sj_coach_deltas_42', '[{"id":1}]');
sessionStorage.setItem('sj_coach_debrief_42', '0');
sessionStorage.setItem('fremd_anderer_key', 'muss_erhalten_bleiben');
});
const vorLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
}));
expect(vorLogout.step).toBe('3');
expect(vorLogout.fremd).toBe('muss_erhalten_bleiben');
// App.jsx DesktopSidebar-Logout zeigt confirm() — in Playwright headless akzeptieren
page.once('dialog', dialog => dialog.accept());
await page.getByRole('button', { name: 'Abmelden' }).click();
await page.waitForLoadState('networkidle');
const nachLogout = await page.evaluate(() => ({
step: sessionStorage.getItem('sj_coach_step_42'),
deltas: sessionStorage.getItem('sj_coach_deltas_42'),
debrief: sessionStorage.getItem('sj_coach_debrief_42'),
fremd: sessionStorage.getItem('fremd_anderer_key'),
token: localStorage.getItem('authToken'),
}));
expect(nachLogout.step).toBeNull();
expect(nachLogout.deltas).toBeNull();
expect(nachLogout.debrief).toBeNull();
expect(nachLogout.fremd).toBe('muss_erhalten_bleiben');
expect(nachLogout.token).toBeNull();
await page.screenshot({ path: 'screenshots/p12-session-storage-nach-logout.png' });
console.log('✓ P-12: sj_coach_* entfernt, Fremd-Key erhalten, authToken entfernt');
});
// P-01: Rechtstextseiten öffentliche Routen ohne Auth
const LEGAL_ROUTES = [
{ path: '/impressum', label: 'Impressum' },
{ path: '/datenschutz', label: 'Datenschutz' },
{ path: '/nutzungsbedingungen', label: 'Nutzungsbedingungen' },
{ path: '/medienrichtlinie', label: 'Medienrichtlinie' },
];
for (const route of LEGAL_ROUTES) {
test(`P-01: ${route.label} ohne Auth erreichbar und enthält Platzhalterhinweis`, async ({ page }) => {
// Direkt aufrufen ohne Login
await page.goto(route.path);
await page.waitForLoadState('networkidle');
// Seite ist erreichbar (kein Redirect zur Login-Seite)
expect(page.url()).toContain(route.path);
// Seitentitel korrekt
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
// Platzhalterhinweis sichtbar nur wenn noch kein echtes Dokument veröffentlicht wurde
const hasPlaceholder = await page.getByText('MUSTER / PLATZHALTER').first().isVisible().catch(() => false);
if (hasPlaceholder) {
console.log(`✓ P-01: ${route.label} Platzhalter sichtbar`);
} else {
console.log(`✓ P-01: ${route.label} echtes Dokument veröffentlicht (kein Platzhalter)`);
}
// Reload funktioniert
await page.reload();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
console.log(`✓ P-01: ${route.label} ohne Auth erreichbar, Reload OK`);
});
}
test('P-01: Login-Seite enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await page.goto('/login');
await page.waitForLoadState('networkidle');
for (const route of LEGAL_ROUTES) {
const link = page.locator(`a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01: Login-Seite alle vier Rechtstext-Links vorhanden');
});
// P-01b: Rechtliches über Einstellungen (Mobile/PWA-Erreichbarkeit)
async function gotoAuthenticated(page, path) {
await page.goto(path);
await page.waitForLoadState('networkidle');
// After full-page reload the app re-checks auth from localStorage; wait for spinner to clear
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 15000 });
}
test('P-01b: Einstellungen enthält Link zu Rechtliches', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Einstellungen' })).toBeVisible({ timeout: 8000 });
const link = page.locator('.app-main a[href="/settings/legal"]');
await expect(link).toBeVisible();
console.log('✓ P-01b: Einstellungen enthält Link zu /settings/legal');
});
test('P-01b: /settings/legal enthält Links zu allen vier Rechtstextseiten', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
for (const route of LEGAL_ROUTES) {
const link = page.locator(`.app-main a[href="${route.path}"]`);
await expect(link).toBeVisible();
}
console.log('✓ P-01b: /settings/legal Überschrift + alle vier Rechtstext-Links vorhanden');
});
test('P-01b: Jeder Rechtstext-Link aus /settings/legal führt zur korrekten Route', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
for (const route of LEGAL_ROUTES) {
await gotoAuthenticated(page, '/settings/legal');
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtliches' })).toBeVisible({ timeout: 8000 });
await page.locator(`.app-main a[href="${route.path}"]`).click();
await page.waitForLoadState('networkidle');
expect(page.url()).toContain(route.path);
await expect(page.getByRole('heading', { level: 1 })).toContainText(route.label);
console.log(`✓ P-01b: ${route.label} Link aus /settings/legal korrekt`);
}
});
// P-01c: Admin-konfigurierbare Rechtstexte
test('P-01c: Rechtstextseiten zeigen Platzhalter-Banner wenn kein published-Dokument', async ({ page }) => {
// Keine Auth — public route
await page.goto('/impressum');
await page.waitForLoadState('networkidle');
// Seite erreichbar (kein Redirect zu /login)
expect(page.url()).toContain('/impressum');
// Spinner weg, dann entweder Platzhalter-Banner oder API-Inhalt (beides OK)
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await expect(page.getByRole('heading', { level: 1 })).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: /impressum lädt ohne Fehler (API-fetch mit Fallback)');
});
test('P-01c: /admin/legal-documents erreichbar für Superadmin', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/legal-documents');
// Superadmin sieht die Seite; normaler Admin landet auf 403 (PlatformAdminRoute)
// Test läuft mit Superadmin-Account (TEST_EMAIL), also Seite sichtbar
await expect(page.locator('.app-main').getByRole('heading', { level: 1, name: 'Rechtstexte verwalten' })).toBeVisible({ timeout: 8000 });
await page.screenshot({ path: 'screenshots/p01c-admin-legal-documents.png' });
console.log('✓ P-01c: /admin/legal-documents erreichbar, Überschrift sichtbar');
});
test('P-01c: Admin-Nav enthält Link zu Rechtstexten', async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 800 });
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/admin/hierarchy');
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
const link = page.locator('a[href="/admin/legal-documents"]');
await expect(link).toBeVisible({ timeout: 5000 });
console.log('✓ P-01c: Admin-Nav enthält Link /admin/legal-documents');
});
// ── P-06: Upload-Einwilligungsdialog ────────────────────────────────────────
test('P-06a: Medienbibliothek lädt ohne Fehler', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
await page.screenshot({ path: 'screenshots/p06a-media-library.png' });
console.log('✓ P-06a: Medienbibliothek erreichbar');
});
test('P-06b: Rechte-Dialog erscheint bei Dateiauswahl', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
// Datei-Upload simulieren (ohne echte Datei Datei-Input finden und Datei setzen)
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
// Rechte-Dialog muss erscheinen
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
await expect(page.locator('[role="dialog"]')).toContainText('Rechte-Erklärung');
await expect(page.locator('[role="dialog"]')).toContainText('VORLÄUFIG');
await page.screenshot({ path: 'screenshots/p06b-rights-dialog.png' });
console.log('✓ P-06b: Rechte-Dialog erscheint bei Dateiauswahl');
});
test('P-06c: Dialog-Abbrechen bricht Upload ab', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
await page.locator('[role="dialog"] button:has-text("Abbrechen")').click();
// Dialog geschlossen, kein Upload-Fortschrittsbalken
await expect(page.locator('[role="dialog"]')).toHaveCount(0, { timeout: 3000 });
await page.screenshot({ path: 'screenshots/p06c-dialog-cancel.png' });
console.log('✓ P-06c: Dialog-Abbrechen schließt Dialog ohne Upload');
});
test('P-06d: Dialog-Bestätigung ohne Pflichtfelder zeigt Fehler', async ({ page }) => {
await login(page);
await expect(page.locator('.spinner')).toHaveCount(0, { timeout: 10000 });
await gotoAuthenticated(page, '/media');
await expect(page.locator('h1')).toContainText('Medienbibliothek', { timeout: 8000 });
const fileInput = page.locator('input[type="file"]').first();
await fileInput.setInputFiles({
name: 'test.png',
mimeType: 'image/png',
buffer: Buffer.from('PNG-Testinhalt'),
});
await expect(page.locator('[role="dialog"]')).toBeVisible({ timeout: 5000 });
// Ohne Felder ausfüllen direkt bestätigen
await page.locator('[role="dialog"] button:has-text("Bestätigen")').click();
// Fehlermeldung im Dialog
const dialog = page.locator('[role="dialog"]');
await expect(dialog).toBeVisible({ timeout: 2000 });
// Fehlermeldung muss sichtbar sein
await expect(dialog.locator('p[style*="danger"], p[style*="color: var(--danger)"]')).toBeVisible({ timeout: 2000 });
await page.screenshot({ path: 'screenshots/p06d-dialog-validation.png' });
console.log('✓ P-06d: Dialog zeigt Fehler ohne Pflichtfelder');
});
test('P-06e: API-Endpoint /api/admin/media-rights/legacy-summary erreichbar (Superadmin)', async ({ request }) => {
// Superadmin-Login via API
const loginRes = await request.post('/api/auth/login', {
data: { email: TEST_EMAIL, password: TEST_PASSWORD },
});
if (!loginRes.ok()) {
console.log('⚠ P-06e: Login fehlgeschlagen Test übersprungen');
return;
}
const loginData = await loginRes.json();
const token = loginData.token;
if (!token) {
console.log('⚠ P-06e: Kein Token Test übersprungen');
return;
}
const res = await request.get('/api/admin/media-rights/legacy-summary', {
headers: { 'X-Auth-Token': token },
});
// Endpoint existiert (200 oder 403 wenn kein Superadmin — aber nicht 404/500)
expect([200, 403]).toContain(res.status());
if (res.status() === 200) {
const data = await res.json();
expect(data).toHaveProperty('total_active_assets');
expect(data).toHaveProperty('legacy_unreviewed');
console.log(`✓ P-06e: Legacy-Summary: ${data.legacy_unreviewed} von ${data.total_active_assets} Medien`);
} else {
console.log('✓ P-06e: Endpoint existiert (403 erwartet für Nicht-Superadmin)');
}
});
test('8. Keine kritischen Console-Fehler', async ({ page }) => {
const errors = [];
page.on('console', msg => {