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
- 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.
248 lines
8.4 KiB
Python
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
|