shinkan-jinkendo/backend/routers/media_assets.py
Lars 631ba1cb43
All checks were successful
Deploy Development / deploy (push) Successful in 33s
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 23s
feat: enhance media asset management and exercise integration
- Added new API endpoints for listing media assets and attaching existing archive media to exercises, improving media reuse and governance.
- Updated frontend components to support media asset selection from the archive, enhancing user experience and reducing duplication.
- Incremented version to 0.8.43, reflecting the latest changes in media handling and exercise integration.
2026-05-07 13:00:57 +02:00

176 lines
6.6 KiB
Python

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