From f9e6e6124412199168523ca2d436fd619d51f083 Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 21:36:35 +0200 Subject: [PATCH 01/21] feat: implement bulk upload functionality for media assets - Added a new API endpoint for bulk uploading media assets, allowing users to upload multiple files in a single request. - Implemented validation for file types and sizes during the upload process, ensuring compliance with allowed formats and limits. - Enhanced the MediaLibraryPage component to support bulk file selection and visibility options, improving user experience. - Updated CSS styles for the upload interface to enhance layout and accessibility. - Added tests to verify the functionality of the new bulk upload feature and its integration with existing media asset management. --- backend/routers/media_assets.py | 204 ++++++++++++++++++++- backend/tests/test_media_assets_archive.py | 26 +++ frontend/src/app.css | 56 ++++++ frontend/src/pages/MediaLibraryPage.jsx | 122 +++++++++++- frontend/src/utils/api.js | 44 +++++ 5 files changed, 444 insertions(+), 8 deletions(-) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 4378131..e57cbd5 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -3,7 +3,10 @@ from __future__ import annotations from typing import Any, Literal, Optional, Union -from fastapi import APIRouter, Depends, HTTPException, Query, Request +import hashlib +from pathlib import Path + +from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile from pydantic import BaseModel, Field, model_validator from club_tenancy import ( @@ -33,6 +36,8 @@ from media_lifecycle import ( from media_storage import get_effective_media_root, path_under_media_root from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible +from routers.exercises import ALLOWED_UPLOAD_MIMES, _upload_limit_bytes + router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) @@ -445,6 +450,190 @@ def _apply_lifecycle_action( raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") +_MAX_BULK_LIBRARY_FILES = 25 + + +def _ingest_library_media_file( + cur: Any, + tenant: TenantContext, + raw: bytes, + filename: Optional[str], + content_type: Optional[str], + visibility: str, + club_id_form: Optional[int], +) -> dict: + """Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media.""" + profile_id = tenant.profile_id + role = tenant.global_role or "" + vis = (visibility or "private").strip().lower() + if vis not in ("private", "club", "official"): + raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit") + + next_cid: Optional[int] = None + if vis == "club": + if club_id_form is None: + raise HTTPException(status_code=400, detail="Verein erforderlich für Sichtbarkeit „Verein“") + next_cid = int(club_id_form) + + assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid) + + max_b = _upload_limit_bytes(tenant) + if len(raw) > max_b: + raise HTTPException( + status_code=413, + detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)", + ) + + mime = (content_type or "").split(";")[0].strip().lower() + if mime not in ALLOWED_UPLOAD_MIMES: + raise HTTPException( + status_code=400, + detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}", + ) + + full_sha = hashlib.sha256(raw).hexdigest() + cur.execute( + """SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets + WHERE sha256 = %s AND lower(trim(visibility)) = %s + AND (club_id IS NOT DISTINCT FROM %s) + LIMIT 1""", + (full_sha, vis, next_cid), + ) + existing_asset = cur.fetchone() + + if existing_asset: + ea = r2d(existing_asset) + lc = (ea.get("lifecycle_state") or "").strip().lower() + if lc == "active": + return { + "status": "duplicate", + "media_asset_id": int(ea["id"]), + "original_filename": ea.get("original_filename"), + } + if lc in ("trash_soft", "trash_hidden"): + raise HTTPException( + status_code=409, + detail={ + "code": "MEDIA_ASSET_IN_TRASH", + "message": ( + "Diese Datei ist inhaltsgleich (SHA-256) mit einem Archiv-Medium im Papierkorb." + ), + "media_asset_id": ea["id"], + "lifecycle_state": lc, + }, + ) + raise HTTPException( + status_code=409, + detail="Es existiert bereits ein Archiv-Eintrag zu dieser Datei in einem nicht nutzbaren Zustand.", + ) + + ext = Path(filename or "").suffix[:12] if filename else "" + if not ext and mime == "image/jpeg": + ext = ".jpg" + elif not ext and mime == "image/png": + ext = ".png" + + media_root = get_effective_media_root(cur) + storage_key = f"exercises/{full_sha}{ext}" + dest_path = path_under_media_root(media_root, storage_key) + if dest_path is None: + raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") + dest_path.parent.mkdir(parents=True, exist_ok=True) + if not dest_path.is_file(): + dest_path.write_bytes(raw) + + cur.execute( + """INSERT INTO media_assets ( + mime_type, byte_size, sha256, original_filename, visibility, club_id, + uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state + ) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active') + RETURNING id""", + ( + mime, + len(raw), + full_sha, + filename or storage_key, + vis, + next_cid, + profile_id, + storage_key, + ), + ) + ar = cur.fetchone() + aid = int(r2d(ar)["id"]) + return {"status": "created", "media_asset_id": aid, "original_filename": filename or storage_key} + + +@router.post("/bulk-upload") +async def bulk_upload_media_assets( + tenant: TenantContext = Depends(get_tenant_context), + files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"), + visibility: str = Form("private"), + club_id: Optional[int] = Form(None), +): + """Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion.""" + if not files: + raise HTTPException(status_code=400, detail="Keine Dateien übermittelt") + if len(files) > _MAX_BULK_LIBRARY_FILES: + raise HTTPException( + status_code=400, + detail=f"Maximal {_MAX_BULK_LIBRARY_FILES} Dateien pro Anfrage", + ) + + results: list[dict[str, Any]] = [] + created = duplicate = failed = 0 + + for uf in files: + fn = uf.filename or "ohne_name" + try: + raw = await uf.read() + if not raw: + results.append({"filename": fn, "ok": False, "detail": "Leere Datei"}) + failed += 1 + continue + with get_db() as conn: + cur = get_cursor(conn) + r = _ingest_library_media_file( + cur, + tenant, + raw, + uf.filename, + uf.content_type, + visibility, + club_id, + ) + results.append({"filename": fn, "ok": True, **r}) + if r["status"] == "created": + created += 1 + else: + duplicate += 1 + except HTTPException as e: + detail = e.detail + if isinstance(detail, dict): + detail_s = detail.get("message") or detail.get("code") or str(detail) + else: + detail_s = str(detail) + results.append( + { + "filename": fn, + "ok": False, + "status_code": e.status_code, + "detail": detail_s, + }, + ) + failed += 1 + except Exception as e: + results.append({"filename": fn, "ok": False, "detail": str(e)}) + failed += 1 + + return { + "results": results, + "created_count": created, + "duplicate_count": duplicate, + "failed_count": failed, + } + + @router.get("") def list_media_assets( tenant: TenantContext = Depends(get_tenant_context), @@ -489,21 +678,22 @@ def list_media_assets( media_kind_sql = "" if mk == "image": - media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%'" + # %% für psycopg2: sonst wird % als Platzhalter-Syntax interpretiert + media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%%'" elif mk == "video": - media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%'" + media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%%'" elif mk == "pdf": media_kind_sql = ( " AND (lower(COALESCE(ma.mime_type, '')) = 'application/pdf'" - " OR lower(COALESCE(ma.mime_type, '')) LIKE '%pdf%')" + " OR lower(COALESCE(ma.mime_type, '')) LIKE '%%pdf%%')" ) elif mk == "other": media_kind_sql = ( " AND COALESCE(ma.mime_type, '') <> ''" - " AND lower(ma.mime_type) NOT LIKE 'image/%'" - " AND lower(ma.mime_type) NOT LIKE 'video/%'" + " AND lower(ma.mime_type) NOT LIKE 'image/%%'" + " AND lower(ma.mime_type) NOT LIKE 'video/%%'" " AND lower(ma.mime_type) <> 'application/pdf'" - " AND lower(ma.mime_type) NOT LIKE '%pdf%'" + " AND lower(ma.mime_type) NOT LIKE '%%pdf%%'" ) club_sql = "" diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index fac7839..d5ba65c 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -87,6 +87,32 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None: assert "viewer" in body +def test_list_media_assets_media_kind_image_ok_mocked(client: TestClient) -> None: + """media_kind=image setzt %% in SQL — Regression gegen psycopg2-%-Platzhalter.""" + app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"} + app.dependency_overrides[get_tenant_context] = lambda: TenantContext( + profile_id=10, + global_role="trainer", + effective_club_id=5, + club_ids=frozenset({5}), + memberships=[], + ) + + mock_cur = MagicMock() + mock_cur.fetchall.side_effect = [[], []] + mock_cm = _mock_db(mock_cur) + + with patch("routers.media_assets.get_db", return_value=mock_cm), patch( + "routers.media_assets.get_cursor", return_value=mock_cur + ), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()): + r = client.get("/api/media-assets?media_kind=image", headers={"X-Auth-Token": "t"}) + + assert r.status_code == 200 + calls = [str(c) for c in mock_cur.execute.call_args_list] + joined = " ".join(calls) + assert "image/%%" in joined + + def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None: app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"} app.dependency_overrides[get_tenant_context] = lambda: TenantContext( diff --git a/frontend/src/app.css b/frontend/src/app.css index cd7d141..7646ae4 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5546,6 +5546,61 @@ a.analysis-split__nav-item { flex: 1 1 140px; max-width: 240px; } +.media-library__upload-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; + margin-top: 12px; +} +.media-library__upload-row .form-input { + min-width: 0; + flex: 0 1 160px; + max-width: 220px; +} +.media-library__upload-summary { + font-size: 0.85rem; + color: var(--text2); + flex: 1 1 200px; +} +.media-library__upload-icon { + vertical-align: middle; + margin-right: 6px; +} +.media-library__sr-file { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + white-space: nowrap; + border: 0; +} +.media-library__card-type { + position: absolute; + left: 6px; + top: 6px; + z-index: 2; + display: inline-flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + border-radius: 8px; + background: rgba(255, 255, 255, 0.92); + color: var(--text2); + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1); + pointer-events: none; +} +.media-library__card-type--compact { + width: 22px; + height: 22px; + left: 3px; + top: 3px; + border-radius: 6px; +} .media-library__card-copyright { position: absolute; right: 6px; @@ -5797,6 +5852,7 @@ a.analysis-split__nav-item { width: 72px; } .media-library__table-thumb { + position: relative; width: 56px; height: 56px; border-radius: 8px; diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 385d01d..3949372 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -1,4 +1,4 @@ -import { useEffect, useState, useCallback } from 'react' +import { useEffect, useState, useCallback, useRef } from 'react' import { Link } from 'react-router-dom' import { LayoutGrid, @@ -13,6 +13,11 @@ import { CircleDot, FilePenLine, Copyright, + Image, + Video, + FileText, + File, + Upload, } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' @@ -195,6 +200,32 @@ function previewDisplayKind(mimeType) { return 'other' } +const MEDIA_KIND_LABELS = { + image: 'Bild', + video: 'Video', + pdf: 'PDF', + other: 'Sonstiges', +} + +function MediaTypeGlyph({ mimeType, compact }) { + const kind = previewDisplayKind(mimeType) + const label = MEDIA_KIND_LABELS[kind] || 'Medium' + let Icon = File + if (kind === 'image') Icon = Image + else if (kind === 'video') Icon = Video + else if (kind === 'pdf') Icon = FileText + const sz = compact ? 12 : 14 + return ( + + + + ) +} + export default function MediaLibraryPage() { const { user } = useAuth() const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin' @@ -222,6 +253,11 @@ export default function MediaLibraryPage() { const [filterClubId, setFilterClubId] = useState('') const [filterUploaderId, setFilterUploaderId] = useState('') const [uploaderFilterOptions, setUploaderFilterOptions] = useState([]) + const bulkFileInputRef = useRef(null) + const [uploadVis, setUploadVis] = useState('private') + const [uploadClubId, setUploadClubId] = useState('') + const [uploadBusy, setUploadBusy] = useState(false) + const [uploadSummary, setUploadSummary] = useState('') const loadClubs = useCallback(async () => { try { @@ -428,6 +464,33 @@ export default function MediaLibraryPage() { const selCount = selected.size + const onBulkArchiveFiles = async (e) => { + const fl = e.target.files + if (!fl?.length) return + const list = Array.from(fl) + e.target.value = '' + if (uploadVis === 'club' && !Number(uploadClubId)) { + window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.') + return + } + setUploadBusy(true) + setUploadSummary('') + try { + const res = await api.bulkUploadMediaAssets(list, { + visibility: uploadVis, + ...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}), + }) + setUploadSummary( + `Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}.`, + ) + await loadItems() + } catch (err) { + window.alert(err.message || String(err)) + } finally { + setUploadBusy(false) + } + } + return (
{isPlatformAdmin ? : null} @@ -541,6 +604,61 @@ export default function MediaLibraryPage() { ) : null}
+
+ + + + {uploadVis === 'club' ? ( + + ) : null} + {uploadSummary ? ( + + {uploadSummary} + + ) : null} +