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
|
|
@ -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
|
|
@ -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
|
||||
|
|
@ -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
|
|
@ -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),
|
||||
)
|
||||
37
backend/migrations/047_legal_documents.sql
Normal 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()
|
||||
);
|
||||
75
backend/migrations/048_media_rights_declarations.sql
Normal 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).';
|
||||
18
backend/migrations/049_media_rights_consent_context.sql
Normal 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?';
|
||||
47
backend/migrations/050_media_audit_log.sql
Normal 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'
|
||||
));
|
||||
73
backend/migrations/051_legal_hold.sql
Normal 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'
|
||||
));
|
||||
73
backend/migrations/052_content_reports.sql
Normal file
|
|
@ -0,0 +1,73 @@
|
|||
-- P-13: Content-Melde-Backend
|
||||
-- Meldungen rechtswidriger Inhalte (DSA-konformes Meldeverfahren, KRIT-03)
|
||||
--
|
||||
-- Architektur: Diese Tabelle traegt alle fachlichen Report-Daten.
|
||||
-- Die bestehende Admin-Inbox (InboxPage.jsx, GET /api/me/inbox/join-requests)
|
||||
-- wird um einen zweiten Abschnitt erweitert, der Content-Reports anzeigt.
|
||||
-- Keine separate Admin-Queue, keine generische inbox_items-Tabelle.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS content_reports (
|
||||
id SERIAL PRIMARY KEY,
|
||||
|
||||
-- Ziel der Meldung (erweiterbar auf weitere Typen)
|
||||
target_type VARCHAR(20) NOT NULL DEFAULT 'media_asset'
|
||||
CHECK (target_type IN ('media_asset', 'exercise')),
|
||||
target_id INTEGER NOT NULL,
|
||||
|
||||
-- Meldungsinhalt
|
||||
report_reason VARCHAR(50) NOT NULL
|
||||
CHECK (report_reason IN (
|
||||
'copyright',
|
||||
'image_rights',
|
||||
'privacy',
|
||||
'minors',
|
||||
'illegal_content',
|
||||
'youth_protection',
|
||||
'offensive_content',
|
||||
'other'
|
||||
)),
|
||||
report_description TEXT NOT NULL,
|
||||
|
||||
-- Meldende Person (Name + E-Mail Pflicht; Profil optional bei eingeloggten Nutzern)
|
||||
reporter_name VARCHAR(200) NOT NULL,
|
||||
reporter_email VARCHAR(200) NOT NULL,
|
||||
reporter_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
|
||||
-- Gutglaubenserklärung (Pflicht)
|
||||
good_faith_confirmed BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
|
||||
-- Automatische Priorisierung (high fuer minors/youth_protection/illegal_content)
|
||||
priority VARCHAR(20) NOT NULL DEFAULT 'normal'
|
||||
CHECK (priority IN ('high', 'normal')),
|
||||
|
||||
-- Workflow-Status
|
||||
status VARCHAR(30) NOT NULL DEFAULT 'submitted'
|
||||
CHECK (status IN (
|
||||
'submitted',
|
||||
'under_review',
|
||||
'resolved_no_action',
|
||||
'resolved_legal_hold',
|
||||
'rejected_invalid'
|
||||
)),
|
||||
|
||||
-- Bearbeitung
|
||||
assigned_to_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
reviewed_by_profile_id INTEGER REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
reviewed_at TIMESTAMP,
|
||||
resolution_note TEXT,
|
||||
|
||||
-- Zeitstempel
|
||||
submitted_at TIMESTAMP DEFAULT NOW(),
|
||||
created_at TIMESTAMP DEFAULT NOW(),
|
||||
updated_at TIMESTAMP DEFAULT NOW()
|
||||
);
|
||||
|
||||
-- Indices fuer Admin-Liste
|
||||
CREATE INDEX IF NOT EXISTS idx_content_reports_status_created
|
||||
ON content_reports (status, created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_content_reports_target
|
||||
ON content_reports (target_type, target_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_content_reports_priority
|
||||
ON content_reports (priority, status, created_at DESC);
|
||||
21
backend/migrations/053_content_report_audit_event.sql
Normal 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'
|
||||
));
|
||||
|
|
@ -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."""
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
931
backend/routers/content_reports.py
Normal 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,
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
475
backend/routers/legal_documents.py
Normal 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]
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
100
backend/tests/test_auth_password_reset_minlength.py
Normal 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
|
||||
|
|
@ -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"},
|
||||
|
|
|
|||
258
backend/tests/test_media_assets_copyright_promotion.py
Normal 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
|
||||
430
backend/tests/test_media_rights_declaration.py
Normal 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"]
|
||||
281
backend/tests/test_p11_legal_hold.py
Normal 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1–2: 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 3–4: 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 5–6: 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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 7–10: 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 11–14: 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"
|
||||
)
|
||||
344
backend/tests/test_p13_content_reports.py
Normal 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"
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
|
|
@ -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.87–0.8.94.**
|
||||
|
||||
**Backend (`backend/routers/content_reports.py`, Migrationen 052–053):**
|
||||
- `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
|
|
@ -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 (001–046) | ✓ |
|
||||
| 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.71–0.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 | 2–5 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 | 5–8 Tage |
|
||||
| KRIT-03 | Kein DSA-Meldeverfahren | Keine Möglichkeit, rechtswidrige Inhalte zu melden. Kein Moderationssystem für UGC-Plattform. | Kein Router vorhanden | Ja | 10–20 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 | 3–7 Tage |
|
||||
| KRIT-05 | Kein DSGVO-Self-Service (Auskunft, Export) | Keine Datenauskunft, kein Datenexport, keine Berichtigungsmöglichkeit (DSGVO Art. 15, 16, 20). | Kein Endpoint | Ja | 5–10 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:766–784` | Ja | 1–2 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 | 5–8 Tage |
|
||||
| HOCH-05 | Kein Admin-Audit-Log | Profil-Löschungen, Lifecycle-Aktionen nicht geloggt | 3–5 Tage |
|
||||
| HOCH-06 | Keine Mindestalter-Abfrage | Keine Schranke gegen Registrierung Minderjähriger | 1–2 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 | 2–3 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 | 2–5 Tage Technik |
|
||||
| P-02 | Self-Service-Kontolöschung + Datenexport | KRIT-02, KRIT-05 | 5–8 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 | 2–4 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 | 3–5 Tage |
|
||||
| P-10 | Mindestalter-Abfrage | HOCH-06 | 1–2 Tage |
|
||||
| P-11 | Legal-Hold Lifecycle-Status | MITT-02 | 2–3 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 | 1–2 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 | 5–8 Tage |
|
||||
| P-14 | Moderations-UI (Frontend) | KRIT-03 | 3–5 Tage |
|
||||
| P-15 | Uploader-Benachrichtigung bei Sperrung | KRIT-03 | 1–2 Tage |
|
||||
| P-16 | Beschwerdeverfahren | KRIT-03 | 2–4 Tage |
|
||||
|
||||
**Etappe 4 – Langfristige Optimierungen**
|
||||
|
||||
| Paket-ID | Titel | Aufwand |
|
||||
|----------|-------|---------|
|
||||
| P-17 | MFA für Superadmins (TOTP) | 5–8 Tage |
|
||||
| P-18 | HttpOnly-Cookie als Auth-Alternative | 3–5 Tage |
|
||||
| P-19 | Anti-Virus-Scan (ClamAV) | 3–5 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.*
|
||||
662
docs/compliance-implementation.md
Normal 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.85–0.8.86)
|
||||
|
||||
#### P-11 Kernumsetzung (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.85–0.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 15–25). 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-06a–P-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.82–0.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.85–0.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.88–0.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-06a–P-06d — Kernumsetzung (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.82–0.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, T1–T10), 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.87–0.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 Kernumsetzung (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 (Kernumsetzung):**
|
||||
|
||||
| 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.88–0.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.84–0.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 T1–T10) und nach Einpflegen der Rechtstexte P-01 durch Betreiber.
|
||||
435
docs/compliance-package-register.md
Normal 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`. **Kernumsetzung (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.82–0.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.82–0.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-13–P-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.85–0.8.86. **Kernumsetzung (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 (Kernumsetzung) + 0.8.88–0.8.94 (Erweiterungen + CI-Fix). **Kernumsetzung (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.88–0.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-13–P-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
|
|
@ -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.66–0.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.87–0.8.94 |
|
||||
|
||||
**Vollständig abgeschlossen:** 10 Hauptpakete + 4 Nacharbeiten + 2 Erweiterungen = 16 Umsetzungseinheiten (P-13 inkl. umfangreicher Nachfixe 0.8.88–0.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-06a–P-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 (T1–T10 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.84–0.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-13–P-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-14–P-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-14–P-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) | 2–4 h Technik | Inhalt durch Rechtsanwalt separat | „Freigabe zur Umsetzung P-01: Rechtstexte" |
|
||||
| P-06 | Upload-Einwilligungsdialog | 2–4 Tage | — | „Freigabe zur Umsetzung P-06: Upload-Einwilligungsdialog" |
|
||||
| ~~P-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.87–0.8.94). Kernumsetzung: `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 | 3–5 Tage | Hoch |
|
||||
| P-10 | Mindestalter-Abfrage | 1–2 Tage | Mittel |
|
||||
| P-14 | Moderations-UI (Frontend) | 3–5 Tage | Mittel (nach P-13) |
|
||||
| P-15 | Uploader-Benachrichtigung bei Sperrung | 1–2 Tage | Mittel (nach P-13) |
|
||||
| P-16 | Beschwerdeverfahren | 2–4 Tage | Niedrig (nach P-15) |
|
||||
| P-08 | HSTS-Dokumentation / Betriebsnachweis | Betreiber | Hoch (Release-Gate) |
|
||||
| P-22 | HTML-Sanitizer für Rich-Text-Felder | 1–2 Tage | Mittel |
|
||||
| P-20 | VVT erstellen | Betreiber | Hoch |
|
||||
| P-21 | AV-Verträge abschließen | Betreiber | Hoch |
|
||||
| P-02 | Self-Service-Kontolöschung + Datenexport | 5–8 Tage | Hoch (nach Spezifikation Etappe C) |
|
||||
| P-17 | MFA für Superadmins (TOTP) | 5–8 Tage | Mittel |
|
||||
| P-18 | HttpOnly-Cookie als Auth-Alternative | 3–5 Tage | Niedrig |
|
||||
| P-19 | Anti-Virus-Scan (ClamAV) | 3–5 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.84–0.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-14–P-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.84–0.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-13–P-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.75–0.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.84–0.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.87–0.8.94) — Kernumsetzung + 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.*
|
||||
984
docs/p06-upload-rights-spec.md
Normal 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-06a–P-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.2–8.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` | 1–n 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.84–0.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.85–0.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-06a–d; 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.1–7.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.1–7.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-06a–P-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 (T1–T10) | 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.82–0.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.82–0.8.83) | Autor: Claude Code | Kein Rechtsanwalt; alle rechtlichen Einschätzungen sind juristisch zu prüfen.*
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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' }}>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
140
frontend/src/components/MediaPreviewModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
200
frontend/src/components/ReportContentModal.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
318
frontend/src/components/RightsDeclarationDialog.jsx
Normal 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 & hochladen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
567
frontend/src/pages/AdminLegalDocumentsPage.jsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
333
frontend/src/pages/LegalPage.jsx
Normal 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
|
||||
|
|
@ -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>
|
||||
)
|
||||
|
|
|
|||
66
frontend/src/pages/SettingsLegalPage.jsx
Normal 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
|
||||
|
|
@ -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>
|
||||
</>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
11
mitai-jinkendo.code-workspace
Normal file
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"folders": [
|
||||
{
|
||||
"path": "../mitai-jinkendo"
|
||||
},
|
||||
{
|
||||
"path": "."
|
||||
}
|
||||
],
|
||||
"settings": {}
|
||||
}
|
||||
BIN
screenshots/01-nach-login.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
screenshots/02-dashboard.png
Normal file
|
After Width: | Height: | Size: 37 KiB |
BIN
screenshots/03-uebungen.png
Normal file
|
After Width: | Height: | Size: 51 KiB |
BIN
screenshots/04-vereine.png
Normal file
|
After Width: | Height: | Size: 35 KiB |
BIN
screenshots/05-desktop-sidebar.png
Normal file
|
After Width: | Height: | Size: 26 KiB |
BIN
screenshots/06-mobile-bottom-nav.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
screenshots/07-nach-reload.png
Normal file
|
After Width: | Height: | Size: 48 KiB |
BIN
screenshots/p01c-admin-legal-documents.png
Normal file
|
After Width: | Height: | Size: 46 KiB |
BIN
screenshots/p12-session-storage-nach-logout.png
Normal file
|
After Width: | Height: | Size: 30 KiB |
|
|
@ -1,4 +1,4 @@
|
|||
{
|
||||
"status": "failed",
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
|
|
@ -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 => {
|
||||
|
|
|
|||