shinkan-jinkendo/backend/media_rights.py
Lars 4bc24b4caf
Some checks failed
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Failing after 1m2s
feat(p06): Copyright-Feld und Einwilligungskontext in Rechte-Erklaerung
Migration 049: 4 optionale TEXT-Spalten in media_asset_rights_declarations
(person_consent_context, parental_consent_context, music_rights_context,
third_party_rights_context) fuer Freitext zum Einwilligungskontext.

Backend:
- media_rights.py: write_rights_declaration speichert 4 Kontextfelder
- media_assets.py: copyright_notice + 4 Kontextfelder in Bulk-Upload,
  RightsDeclarationBody, MediaAssetPatch, MediaBulkPatchBody
- exercises.py: copyright_notice + 4 Kontextfelder in upload_exercise_media,
  wird in INSERT gespeichert

Frontend (alle 3 Formulare):
- RightsDeclarationDialog: Copyright-Eingabefeld (immer sichtbar) +
  Freitext-Textarea bei jeder Ja-Antwort (Personen, Minderjaehrige,
  Musik, Fremdinhalte)
- ExerciseInlineFileMediaModal: gleiche Felder inline im Upload-Tab
- ExerciseInlineEmbedModal: gleiche Felder inline
- api.js: copyright_notice + 4 Kontextfelder in bulkUploadMediaAssets

version: 0.8.77
module: media_rights 1.1.0, media_assets 1.14.0, exercises 2.21.0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-11 09:06:47 +02:00

360 lines
13 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
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
'insufficient' - Erklaerung vorhanden, aber fuer niedrigere Sichtbarkeit
'legacy' - Altmedium ohne Erklaerung
'blocked' - durch Admin gesperrt
'no_declaration' - neues Medium ohne Erklaerung (sollte nicht vorkommen)
"""
cur.execute(
"SELECT rights_status, rights_declared_for_visibility FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
return "no_declaration"
# psycopg2 RealDictCursor oder ähnlich
if hasattr(row, "keys"):
rs = row["rights_status"]
rdv = row["rights_declared_for_visibility"]
else:
rs, rdv = row[0], row[1]
rs = (rs or "").strip().lower()
rdv = (rdv or "").strip().lower() if rdv else None
if rs == "blocked":
return "blocked"
if rs == "legacy_unreviewed":
return "legacy"
if rs == "declared":
if rights_covers_target(rdv, target_visibility):
return "ok"
return "insufficient"
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 == "insufficient":
raise HTTPException(
status_code=400,
detail={
"code": "RIGHTS_SCOPE_INSUFFICIENT",
"message": (
f"Die vorhandene Erklaerung gilt nicht fuer die Ziel-Sichtbarkeit '{target_visibility}'. "
"Bitte eine neue Erklaerung fuer diese Sichtbarkeit abgeben."
),
"asset_id": asset_id,
"target_visibility": target_visibility,
},
)
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 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),
)