MediaPfad extern, Upload Manager Bug Fixes #23

Merged
Lars merged 21 commits from develop into main 2026-05-08 11:17:12 +02:00
5 changed files with 444 additions and 8 deletions
Showing only changes of commit f9e6e61244 - Show all commits

View File

@ -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 = ""

View File

@ -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(

View File

@ -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;

View File

@ -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 (
<span
className={`media-library__card-type${compact ? ' media-library__card-type--compact' : ''}`}
title={label}
aria-label={`Medientyp: ${label}`}
>
<Icon size={sz} strokeWidth={2} aria-hidden />
</span>
)
}
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 (
<div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
@ -541,6 +604,61 @@ export default function MediaLibraryPage() {
</select>
) : null}
</div>
<div className="media-library__upload-row" aria-label="Archiv hochladen">
<input
ref={bulkFileInputRef}
type="file"
className="media-library__sr-file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf,.jpg,.jpeg,.png,.gif,.mp4,.pdf"
multiple
onChange={onBulkArchiveFiles}
/>
<button
type="button"
className="btn btn-secondary"
disabled={uploadBusy}
onClick={() => bulkFileInputRef.current?.click()}
title="Mehrere Dateien ins Archiv laden"
>
<Upload size={18} aria-hidden className="media-library__upload-icon" />
Archiv-Upload
</button>
<select
className="form-input"
value={uploadVis}
onChange={(e) => {
setUploadVis(e.target.value)
setUploadSummary('')
}}
aria-label="Sichtbarkeit für neuen Upload"
>
{VIS_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
Upload: {o.label}
</option>
))}
</select>
{uploadVis === 'club' ? (
<select
className="form-input"
value={uploadClubId}
onChange={(e) => setUploadClubId(e.target.value)}
aria-label="Verein für Upload"
>
<option value="">Verein wählen</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
{uploadSummary ? (
<span className="media-library__upload-summary" role="status">
{uploadSummary}
</span>
) : null}
</div>
<div className="media-library__toolbar-meta">
<label className="media-library__check-all">
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
@ -591,6 +709,7 @@ export default function MediaLibraryPage() {
title="Vorschau"
>
<div className="media-library__card-thumb-wrap">
<MediaTypeGlyph mimeType={it.mime_type} />
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
{(it.copyright_notice || '').trim() ? (
<span
@ -658,6 +777,7 @@ export default function MediaLibraryPage() {
title="Vorschau"
>
<div className="media-library__table-thumb">
<MediaTypeGlyph mimeType={it.mime_type} compact />
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</button>

View File

@ -564,6 +564,49 @@ export async function bulkPatchMediaAssets(data) {
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
@ -1313,6 +1356,7 @@ export const api = {
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
bulkUploadMediaAssets,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,