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