"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC.""" from __future__ import annotations from typing import Any, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel from club_tenancy import is_platform_admin, library_content_visible_to_profile from db import get_db, get_cursor, r2d from media_lifecycle import fetch_media_asset_row from media_storage import get_effective_media_root, path_under_media_root from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) class MediaLifecycleBody(BaseModel): action: Literal["trash_soft", "trash_hidden", "recover", "purge"] def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, storage_key, mime_type, original_filename FROM media_assets WHERE id = %s""", (asset_id,), ) row = cur.fetchone() return r2d(row) if row else None def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict) -> None: if not library_content_visible_to_profile( cur, tenant.profile_id, (asset.get("visibility") or "").strip().lower(), asset.get("club_id"), asset.get("uploaded_by_profile_id"), tenant.global_role, ): raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") @router.get("") def list_media_assets( tenant: TenantContext = Depends(get_tenant_context), q: Optional[str] = Query(None, max_length=120), limit: int = Query(30, ge=1, le=100), offset: int = Query(0, ge=0), ): """ Durchsuchbares Medien-Archiv: nur aktive Assets, Sichtbarkeit wie Übungsbibliothek. """ role = tenant.global_role or "" is_adm = is_platform_admin(role) profile_id = tenant.profile_id needle = (q or "").strip() params: list[Any] = [is_adm, profile_id, profile_id] search_sql = "" if needle: like = f"%{needle}%" params.extend([like, like]) search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)" params.extend([limit, offset]) with get_db() as conn: cur = get_cursor(conn) cur.execute( f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id, ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256 FROM media_assets ma WHERE ma.lifecycle_state = 'active' AND ( %s OR lower(trim(ma.visibility)) = 'official' OR ( lower(trim(ma.visibility)) = 'private' AND ma.uploaded_by_profile_id = %s ) OR ( lower(trim(ma.visibility)) = 'club' AND EXISTS ( SELECT 1 FROM club_members cm WHERE cm.profile_id = %s AND cm.club_id = ma.club_id AND cm.status = 'active' ) ) ) {search_sql} ORDER BY ma.created_at DESC LIMIT %s OFFSET %s""", params, ) rows = [r2d(r) for r in cur.fetchall()] return {"items": rows, "limit": limit, "offset": offset} @router.api_route("/{asset_id}/file", methods=["GET", "HEAD"]) def download_media_asset_file( request: Request, asset_id: int, tenant: TenantContext = Depends(get_tenant_context_flexible), ): """Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken).""" from routers.exercises import _binary_media_response with get_db() as conn: cur = get_cursor(conn) asset = _fetch_asset_file_row(cur, asset_id) if not asset: raise HTTPException(status_code=404, detail="Medium nicht gefunden") lc = (asset.get("lifecycle_state") or "").strip().lower() if lc != "active": raise HTTPException(status_code=404, detail="Medium nicht verfügbar") _assert_can_view_archive_asset(cur, tenant, asset) sk = asset.get("storage_key") if not sk: raise HTTPException(status_code=404, detail="Keine Datei hinterlegt") media_root = get_effective_media_root(cur) abs_p = path_under_media_root(media_root, str(sk)) if not abs_p or not abs_p.is_file(): raise HTTPException(status_code=404, detail="Datei nicht gefunden") mime = asset.get("mime_type") or "application/octet-stream" fname = asset.get("original_filename") or abs_p.name return _binary_media_response(abs_p, mime, str(fname) if fname else None, request) @router.post("/{asset_id}/lifecycle") def post_media_asset_lifecycle( asset_id: int, body: MediaLifecycleBody, tenant: TenantContext = Depends(get_tenant_context), ): """Papierkorb-Übergänge — media_lifecycle.""" from media_lifecycle import ( assert_can_manage_media_asset_lifecycle, purge_media_asset, transition_recover_from_hidden, transition_to_trash_hidden, transition_to_trash_soft, ) with get_db() as conn: cur = get_cursor(conn) asset = fetch_media_asset_row(cur, asset_id) if not asset: raise HTTPException(status_code=404, detail="Medium nicht gefunden") assert_can_manage_media_asset_lifecycle(cur, tenant, asset) action = body.action if action == "trash_soft": return transition_to_trash_soft(cur, conn, asset_id) if action == "trash_hidden": return transition_to_trash_hidden(cur, conn, asset_id) if action == "recover": return transition_recover_from_hidden(cur, conn, asset_id) if action == "purge": state = (asset.get("lifecycle_state") or "").strip().lower() if state != "trash_hidden": raise HTTPException( status_code=400, detail="Nur ausgeblendete Medien (Stufe 2) dürfen endgültig gelöscht werden", ) ok = purge_media_asset(cur, conn, asset_id) if not ok: raise HTTPException(status_code=400, detail="Löschen nicht möglich") return {"ok": True, "purged": asset_id} raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")