shinkan-jinkendo/backend/media_legal_hold.py
Lars 1ce6d929ce
All checks were successful
Deploy Development / deploy (push) Successful in 40s
Test Suite / pytest-backend (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 10s
Test Suite / playwright-tests (push) Successful in 54s
feat(P-11): Implement Legal Hold functionality for media assets
- Added backend support for Legal Hold with new endpoints to set and release holds on media assets.
- Introduced new database columns for managing Legal Hold status and reasons.
- Updated frontend to include UI elements for setting and releasing Legal Holds, including a confirmation dialog.
- Enhanced Media Library page to display Legal Hold status and actions for superadmins.
- Implemented comprehensive backend tests covering all aspects of Legal Hold functionality.
- Updated documentation to reflect changes in the upload rights specification and interface models.
- Bumped version to 0.8.84 and updated MediaLibraryPage version to 1.6.0.
2026-05-11 12:33:13 +02:00

248 lines
8.4 KiB
Python

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