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.
313 lines
12 KiB
Python
313 lines
12 KiB
Python
"""
|
|
Medien-Lebenszyklus (Papierkorb) — siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §5.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import logging
|
|
import os
|
|
from datetime import datetime, timedelta, timezone
|
|
from typing import Any, Optional
|
|
|
|
from fastapi import HTTPException
|
|
|
|
from club_tenancy import can_manage_club_org, is_platform_admin, is_superadmin
|
|
from db import r2d
|
|
from media_storage import get_effective_media_root, path_under_media_root
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
LC_ACTIVE = "active"
|
|
LC_TRASH_SOFT = "trash_soft"
|
|
LC_TRASH_HIDDEN = "trash_hidden"
|
|
|
|
SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30")))
|
|
# P-03b: Default gemaess fachlichem Loeschkonzept (Audit 2026-05-09): 30+30 Tage.
|
|
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "30")))
|
|
|
|
|
|
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
|
|
"""
|
|
Papierkorb Stufe 2 / Recovery / Reaktivierung — nicht für trash_soft (siehe assert_can_trash_soft).
|
|
§5.2: official nur Superadmin; club Vereinsorga; privat nur Uploader (Plattform-Admin sonst wie bisher).
|
|
"""
|
|
profile_id = tenant.profile_id
|
|
role = (tenant.global_role or "").strip().lower()
|
|
vis = (asset.get("visibility") or "private").strip().lower()
|
|
if vis == "official":
|
|
if not is_superadmin(role):
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Offizielle Medien dürfen nur von Superadmins geändert oder gelöscht werden",
|
|
)
|
|
return
|
|
|
|
if is_platform_admin(role):
|
|
return
|
|
uid = asset.get("uploaded_by_profile_id")
|
|
if vis == "private":
|
|
if uid is not None and int(uid) == int(profile_id):
|
|
return
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
|
|
if vis == "club":
|
|
cid = asset.get("club_id")
|
|
if cid is None:
|
|
raise HTTPException(status_code=403, detail="Ungültiges Vereins-Medium")
|
|
if can_manage_club_org(cur, profile_id, int(cid), role):
|
|
return
|
|
raise HTTPException(status_code=403, detail="Nur Vereinsorganisation/Plattform-Admin")
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
|
|
|
|
|
|
def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None:
|
|
"""
|
|
Aktiv → Papierkorb (Stufe 1). Trainer: nur eigene private Uploads.
|
|
Vereinsmedien: Vereinsorga; official: nur Superadmin; Plattform-Admin: sonst wie Verein/privat.
|
|
"""
|
|
role_raw = tenant.global_role
|
|
role = (role_raw or "").strip().lower()
|
|
if is_superadmin(role):
|
|
return
|
|
vis = (asset.get("visibility") or "private").strip().lower()
|
|
if vis == "official":
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Offizielle Medien dürfen nur von Superadmins in den Papierkorb gelegt werden",
|
|
)
|
|
if is_platform_admin(role):
|
|
return
|
|
uid = asset.get("uploaded_by_profile_id")
|
|
cid = asset.get("club_id")
|
|
pid = int(tenant.profile_id)
|
|
if vis == "private":
|
|
if uid is not None and int(uid) == pid:
|
|
return
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Nur eigene private Medien dürfen in den Papierkorb",
|
|
)
|
|
if vis == "club":
|
|
if cid is not None and can_manage_club_org(cur, pid, int(cid), role_raw):
|
|
return
|
|
raise HTTPException(
|
|
status_code=403,
|
|
detail="Nur Vereinsorganisation darf Vereinsmedien in den Papierkorb legen",
|
|
)
|
|
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
|
|
|
|
|
|
def assert_can_edit_media_asset_metadata(cur: Any, tenant: Any, asset: dict) -> None:
|
|
"""PATCH Metadaten / Sichtbarkeit — gleiche Stufen wie Lifecycle (ohne Papierkorb)."""
|
|
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
|
|
|
|
|
def superadmin_force_lifecycle_state(cur: Any, conn: Any, asset_id: int, target: str) -> dict:
|
|
"""Nur Superadmin: Zustand direkt setzen."""
|
|
if target not in (LC_ACTIVE, LC_TRASH_SOFT, LC_TRASH_HIDDEN):
|
|
raise HTTPException(status_code=400, detail="Ungültiger Ziel-Lifecycle")
|
|
if target == LC_ACTIVE:
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, updated_at = NOW(),
|
|
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
|
|
WHERE id = %s
|
|
RETURNING id, lifecycle_state""",
|
|
(LC_ACTIVE, asset_id),
|
|
)
|
|
elif target == LC_TRASH_SOFT:
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, trash_soft_at = COALESCE(trash_soft_at, NOW()),
|
|
trash_hidden_at = NULL, purge_after_at = NULL, updated_at = NOW()
|
|
WHERE id = %s
|
|
RETURNING id, lifecycle_state, trash_soft_at""",
|
|
(LC_TRASH_SOFT, asset_id),
|
|
)
|
|
else:
|
|
pa = datetime.now(timezone.utc) + timedelta(days=HIDDEN_TO_PURGE_DAYS)
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, trash_hidden_at = NOW(),
|
|
purge_after_at = %s, updated_at = NOW(),
|
|
trash_soft_at = COALESCE(trash_soft_at, NOW())
|
|
WHERE id = %s
|
|
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
|
|
(LC_TRASH_HIDDEN, pa, asset_id),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
|
conn.commit()
|
|
return r2d(row)
|
|
|
|
|
|
def superadmin_hard_delete_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
|
|
"""Nur Superadmin: Zeile + Datei unabhängig vom Lifecycle entfernen."""
|
|
cur.execute(
|
|
"SELECT id, storage_key FROM media_assets WHERE id = %s",
|
|
(asset_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return False
|
|
asset = r2d(row)
|
|
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
|
|
purge_asset_filesystem(cur, asset)
|
|
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]:
|
|
cur.execute(
|
|
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
|
storage_key, storage_backend, trash_soft_at, trash_hidden_at, purge_after_at
|
|
FROM media_assets WHERE id = %s""",
|
|
(asset_id,),
|
|
)
|
|
row = cur.fetchone()
|
|
return r2d(row) if row else None
|
|
|
|
|
|
def purge_asset_filesystem(cur: Any, asset: dict) -> None:
|
|
sk = asset.get("storage_key")
|
|
if not sk:
|
|
return
|
|
root = get_effective_media_root(cur)
|
|
p = path_under_media_root(root, str(sk))
|
|
if p and p.is_file():
|
|
try:
|
|
p.unlink()
|
|
except OSError as e:
|
|
logger.warning("Physische Medien-Löschung fehlgeschlagen: %s", e)
|
|
|
|
|
|
def purge_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
|
|
"""Löscht Verknüpfungen, Datei und DB-Zeile. Returns True wenn ausgeführt."""
|
|
cur.execute(
|
|
"""SELECT id, storage_key FROM media_assets
|
|
WHERE id = %s AND lifecycle_state = %s""",
|
|
(asset_id, LC_TRASH_HIDDEN),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return False
|
|
asset = r2d(row)
|
|
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
|
|
purge_asset_filesystem(cur, asset)
|
|
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
|
|
conn.commit()
|
|
return True
|
|
|
|
|
|
def transition_to_trash_soft(cur: Any, conn: Any, asset_id: int) -> dict:
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, trash_soft_at = NOW(), updated_at = NOW(),
|
|
trash_hidden_at = NULL, purge_after_at = NULL
|
|
WHERE id = %s AND lifecycle_state = %s
|
|
RETURNING id, lifecycle_state, trash_soft_at""",
|
|
(LC_TRASH_SOFT, asset_id, LC_ACTIVE),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=400, detail="Nur aktive Medien können in den Papierkorb")
|
|
conn.commit()
|
|
return r2d(row)
|
|
|
|
|
|
def reactivate_media_asset_from_trash(cur: Any, conn: Any, asset_id: int) -> dict:
|
|
"""
|
|
Stufe 1 oder 2 → wieder aktiv (z. B. erneuter Upload derselben Datei / explizite Wiederherstellung).
|
|
"""
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, updated_at = NOW(),
|
|
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
|
|
WHERE id = %s AND lifecycle_state IN (%s, %s)
|
|
RETURNING id, lifecycle_state""",
|
|
(LC_ACTIVE, asset_id, LC_TRASH_SOFT, LC_TRASH_HIDDEN),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Nur Medien aus dem Papierkorb (Stufe 1 oder 2) können reaktiviert werden",
|
|
)
|
|
conn.commit()
|
|
return r2d(row)
|
|
|
|
|
|
def transition_to_trash_hidden(cur: Any, conn: Any, asset_id: int, *, set_purge_after: Optional[datetime] = None) -> dict:
|
|
now = datetime.now(timezone.utc)
|
|
if set_purge_after is None:
|
|
set_purge_after = now + timedelta(days=HIDDEN_TO_PURGE_DAYS)
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
|
|
purge_after_at = %s
|
|
WHERE id = %s AND lifecycle_state IN (%s, %s)
|
|
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
|
|
(LC_TRASH_HIDDEN, set_purge_after, asset_id, LC_ACTIVE, LC_TRASH_SOFT),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(
|
|
status_code=400,
|
|
detail="Nur aktive oder Papierkorb-Stufe-1 Medien können ausgeblendet werden",
|
|
)
|
|
conn.commit()
|
|
return r2d(row)
|
|
|
|
|
|
def transition_recover_from_hidden(cur: Any, conn: Any, asset_id: int) -> dict:
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, updated_at = NOW(),
|
|
trash_hidden_at = NULL, purge_after_at = NULL
|
|
WHERE id = %s AND lifecycle_state = %s
|
|
RETURNING id, lifecycle_state, trash_soft_at""",
|
|
(LC_TRASH_SOFT, asset_id, LC_TRASH_HIDDEN),
|
|
)
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise HTTPException(status_code=400, detail="Nur ausgeblendete Medien können zurückgestuft werden")
|
|
conn.commit()
|
|
return r2d(row)
|
|
|
|
|
|
def run_retention_pass(cur: Any, conn: Any) -> dict:
|
|
"""
|
|
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS → trash_hidden;
|
|
trash_hidden mit purge_after_at in der Vergangenheit → purge.
|
|
|
|
P-11: Medien unter aktivem Legal Hold werden NICHT gerpurged (legal_hold_active = TRUE).
|
|
Die Retention verschiebt sie auch nicht automatisch von trash_soft nach trash_hidden —
|
|
Legal-Hold-Status hat Vorrang vor dem Papierkorb-Lifecycle.
|
|
"""
|
|
cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
|
|
cur.execute(
|
|
"""UPDATE media_assets
|
|
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
|
|
purge_after_at = NOW() + (%s * INTERVAL '1 day')
|
|
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s
|
|
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)
|
|
RETURNING id""",
|
|
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft),
|
|
)
|
|
n_hidden = len(cur.fetchall())
|
|
conn.commit()
|
|
|
|
cur.execute(
|
|
"""SELECT id FROM media_assets
|
|
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()
|
|
AND (legal_hold_active = FALSE OR legal_hold_active IS NULL)""",
|
|
(LC_TRASH_HIDDEN,),
|
|
)
|
|
purge_ids = [r2d(r)["id"] for r in cur.fetchall()]
|
|
purged = 0
|
|
for aid in purge_ids:
|
|
if purge_media_asset(cur, conn, int(aid)):
|
|
purged += 1
|
|
|
|
return {"moved_to_hidden": n_hidden, "purged": purged}
|