shinkan-jinkendo/backend/media_rights.py
Lars bacba311ae
Some checks failed
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Failing after 0s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 3m41s
feat(P-13): implement content reporting enhancements, including email notifications and audit log entries
2026-05-11 19:36:23 +02:00

413 lines
15 KiB
Python

"""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),
)