shinkan-jinkendo/backend/media_lifecycle.py
Lars 8ac723eafe
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 24s
feat: enhance media lifecycle management and inline media integration
- Implemented media lifecycle management with new API endpoints for handling asset states (trash_soft, trash_hidden, recover, purge), improving media governance.
- Updated frontend components to filter and display media based on lifecycle states, enhancing user experience and visibility.
- Enhanced documentation in MEDIA_ASSETS_AND_ARCHIVE_SPEC.md to include guidelines for inline media references in exercise texts, establishing a clear implementation plan.
- Incremented version to 0.8.42, reflecting the latest changes in media handling and lifecycle management.
2026-05-07 12:55:50 +02:00

178 lines
6.4 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
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")))
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90")))
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
"""
Wer Medien in Papierkorb / Recovery / Purge versetzen darf (§5.2 Kurzfassung).
"""
profile_id = tenant.profile_id
role = (tenant.global_role or "").strip().lower()
if is_platform_admin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
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 == "official":
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
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 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 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.
"""
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
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()""",
(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}