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