Some checks failed
Deploy Development / deploy (push) Failing after 18s
Test Suite / pytest-backend (push) Successful in 32s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Failing after 3s
Test Suite / playwright-tests (push) Successful in 55s
413 lines
15 KiB
Python
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: 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),
|
|
)
|