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