MediaPfad extern, Upload Manager Bug Fixes #23

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

View File

@ -2,10 +2,24 @@
from __future__ import annotations from __future__ import annotations
import os import os
import re
import shutil import shutil
import unicodedata
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
_CLUB_SLUG_TRANS = str.maketrans(
{
"ä": "ae",
"ö": "oe",
"ü": "ue",
"ß": "ss",
"Ä": "ae",
"Ö": "oe",
"Ü": "ue",
}
)
def _default_media_root() -> Path: def _default_media_root() -> Path:
return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))) return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")))
@ -82,6 +96,32 @@ def library_media_kind_dir(mime_type: Optional[str], ext: str) -> str:
return "other" return "other"
def library_club_path_segment(club_id: int, club_name: Optional[str]) -> str:
"""
Ein Verzeichnissegment pro Verein: aus Anzeigenamen abgeleitet + -c{id} für Eindeutigkeit
(Umbenennung, Kollisionen nach Slugify). Nur [a-z0-9-], keine Pfad-Sonderzeichen.
Fehlt der Name / Slug leer verein-c{id}.
"""
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
raw = unicodedata.normalize("NFKC", (club_name or "").strip()).translate(_CLUB_SLUG_TRANS).lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
raw = raw.strip("-")
if len(raw) > 48:
raw = raw[:48].rstrip("-")
if not raw:
base = f"verein-c{cid}"
else:
base = f"{raw}-c{cid}"
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", base):
raise ValueError("Ungültiger abgeleiteter Vereins-Ordnername")
return base
def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]: def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal.""" """Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
key = (storage_key or "").strip().replace("\\", "/").lstrip("/") key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
@ -103,13 +143,16 @@ def library_storage_key(
*, *,
uploader_profile_id: Optional[int] = None, uploader_profile_id: Optional[int] = None,
mime_type: Optional[str] = None, mime_type: Optional[str] = None,
club_name: Optional[str] = None,
) -> str: ) -> str:
""" """
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets. Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
- official library/official/{kind}/{sha256}{ext} - official library/official/{kind}/{sha256}{ext}
- club (vereinsgeteilt) library/club/c{club_id}/{kind}/{sha256}{ext} - club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext}
- private (nur Hochlader, Governance unverändert) library/club/c{club_id}/u{profile}/{kind}/{sha256}{ext} - private (nur Hochlader) library/{vereins-segment}/u{profile}/{kind}/{sha256}{ext}
Vereins-Segment: aus club_name abgeleitet + -c{club_id} siehe library_club_path_segment.
kind {image, video, pdf, other} siehe library_media_kind_dir. kind {image, video, pdf, other} siehe library_media_kind_dir.
@ -141,14 +184,15 @@ def library_storage_key(
cid = int(club_id) cid = int(club_id)
if cid < 1: if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein") raise ValueError("club_id muss eine positive Ganzzahl sein")
club_seg = library_club_path_segment(cid, club_name)
if vis == "club": if vis == "club":
return f"library/club/c{cid}/{blob}" return f"library/{club_seg}/{blob}"
if uploader_profile_id is None: if uploader_profile_id is None:
raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)") raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)")
up = int(uploader_profile_id) up = int(uploader_profile_id)
if up < 1: if up < 1:
raise ValueError("uploader_profile_id muss positiv sein") raise ValueError("uploader_profile_id muss positiv sein")
return f"library/club/c{cid}/u{up}/{blob}" return f"library/{club_seg}/u{up}/{blob}"
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None: def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:

View File

@ -2518,6 +2518,11 @@ async def upload_exercise_media(
}, },
) )
else: else:
club_nm = ""
if dedupe_club is not None:
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(dedupe_club),))
cnr = cur.fetchone()
club_nm = str(r2d(cnr).get("name") or "") if cnr else ""
try: try:
storage_key = library_storage_key( storage_key = library_storage_key(
ex_vis, ex_vis,
@ -2526,6 +2531,7 @@ async def upload_exercise_media(
ext, ext,
uploader_profile_id=profile_id if ex_vis == "private" else None, uploader_profile_id=profile_id if ex_vis == "private" else None,
mime_type=mime, mime_type=mime,
club_name=club_nm,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e

View File

@ -312,6 +312,15 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
return eff return eff
def _club_display_name_for_storage(cur: Any, club_id: int) -> str:
"""Anzeigename für library_club_path_segment; leer wenn Verein fehlt."""
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(club_id),))
row = cur.fetchone()
if not row:
return ""
return str(r2d(row).get("name") or "")
def _relocate_asset_file_if_governance_changed( def _relocate_asset_file_if_governance_changed(
cur: Any, cur: Any,
media_root: Path, media_root: Path,
@ -334,6 +343,9 @@ def _relocate_asset_file_if_governance_changed(
try: try:
up = asset.get("uploaded_by_profile_id") up = asset.get("uploaded_by_profile_id")
up_i = int(up) if up is not None else None up_i = int(up) if up is not None else None
club_nm: Optional[str] = None
if next_vis in ("club", "private") and next_club_id is not None:
club_nm = _club_display_name_for_storage(cur, next_club_id)
new_key = library_storage_key( new_key = library_storage_key(
next_vis, next_vis,
next_club_id if next_vis != "official" else None, next_club_id if next_vis != "official" else None,
@ -341,6 +353,7 @@ def _relocate_asset_file_if_governance_changed(
ext, ext,
uploader_profile_id=up_i if next_vis == "private" else None, uploader_profile_id=up_i if next_vis == "private" else None,
mime_type=str(asset.get("mime_type") or "") or None, mime_type=str(asset.get("mime_type") or "") or None,
club_name=club_nm,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e
@ -623,6 +636,10 @@ def _ingest_library_media_file(
elif not ext and mime == "video/quicktime": elif not ext and mime == "video/quicktime":
ext = ".mov" ext = ".mov"
club_nm = ""
if next_cid is not None:
club_nm = _club_display_name_for_storage(cur, next_cid)
media_root = get_effective_media_root(cur) media_root = get_effective_media_root(cur)
try: try:
storage_key = library_storage_key( storage_key = library_storage_key(
@ -632,6 +649,7 @@ def _ingest_library_media_file(
ext, ext,
uploader_profile_id=profile_id if vis == "private" else None, uploader_profile_id=profile_id if vis == "private" else None,
mime_type=mime, mime_type=mime,
club_name=club_nm,
) )
except ValueError as e: except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e raise HTTPException(status_code=400, detail=str(e)) from e

View File

@ -3,7 +3,7 @@ from __future__ import annotations
import pytest import pytest
from media_storage import library_media_kind_dir, library_storage_key from media_storage import library_club_path_segment, library_media_kind_dir, library_storage_key
_HEX64 = "a" * 64 _HEX64 = "a" * 64
@ -13,17 +13,32 @@ def test_library_media_kind_dir_mime_overrides_ext() -> None:
assert library_media_kind_dir("application/pdf", ".tmp") == "pdf" assert library_media_kind_dir("application/pdf", ".tmp") == "pdf"
def test_library_club_path_segment_slug_and_fallback() -> None:
assert library_club_path_segment(3, "Grün & Weiß / Dojo!") == "gruen-weiss-dojo-c3"
assert library_club_path_segment(3, None) == "verein-c3"
assert library_club_path_segment(3, " ") == "verein-c3"
assert library_club_path_segment(12, "東道場") == "verein-c12"
def test_library_club_path_segment_long_name_truncated() -> None:
long = "a" * 80
seg = library_club_path_segment(5, long)
assert seg == "a" * 48 + "-c5"
def test_library_storage_key_private_is_uploader_under_club() -> None: def test_library_storage_key_private_is_uploader_under_club() -> None:
assert ( assert (
library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99) library_storage_key(
== f"library/club/c7/u99/image/{_HEX64}.jpg" "private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München"
)
== f"library/ost-dojo-muenchen-c7/u99/image/{_HEX64}.jpg"
) )
def test_library_storage_key_club_flat_under_club() -> None: def test_library_storage_key_club_flat_under_club() -> None:
assert ( assert (
library_storage_key("club", 42, _HEX64, ".png") library_storage_key("club", 42, _HEX64, ".png", club_name="Demo-Verein")
== f"library/club/c42/image/{_HEX64}.png" == f"library/demo-verein-c42/image/{_HEX64}.png"
) )
@ -36,8 +51,8 @@ def test_library_storage_key_official() -> None:
def test_library_storage_key_normalizes_visibility() -> None: def test_library_storage_key_normalizes_visibility() -> None:
assert ( assert (
library_storage_key(" CLUB ", 1, _HEX64, "pdf") library_storage_key(" CLUB ", 1, _HEX64, "pdf", club_name="Alpha")
== f"library/club/c1/pdf/{_HEX64}.pdf" == f"library/alpha-c1/pdf/{_HEX64}.pdf"
) )
@ -50,7 +65,7 @@ def test_library_storage_key_other_bin() -> None:
def test_library_storage_key_private_requires_uploader() -> None: def test_library_storage_key_private_requires_uploader() -> None:
with pytest.raises(ValueError, match="uploader_profile_id"): with pytest.raises(ValueError, match="uploader_profile_id"):
library_storage_key("private", 1, _HEX64, ".jpg") library_storage_key("private", 1, _HEX64, ".jpg", club_name="V")
def test_library_storage_key_private_requires_club() -> None: def test_library_storage_key_private_requires_club() -> None:
@ -60,24 +75,33 @@ def test_library_storage_key_private_requires_club() -> None:
def test_library_storage_key_club_requires_id() -> None: def test_library_storage_key_club_requires_id() -> None:
with pytest.raises(ValueError, match="Verein"): with pytest.raises(ValueError, match="Verein"):
library_storage_key("club", None, _HEX64, ".jpg") library_storage_key("club", None, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_club_id_positive() -> None: def test_library_storage_key_club_id_positive() -> None:
with pytest.raises(ValueError, match="positiv"): with pytest.raises(ValueError, match="positiv"):
library_storage_key("club", 0, _HEX64, ".jpg") library_storage_key("club", 0, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_invalid_visibility() -> None: def test_library_storage_key_invalid_visibility() -> None:
with pytest.raises(ValueError, match="Sichtbarkeit"): with pytest.raises(ValueError, match="Sichtbarkeit"):
library_storage_key("public", 1, _HEX64, ".jpg") library_storage_key("public", 1, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_invalid_sha() -> None: def test_library_storage_key_invalid_sha() -> None:
with pytest.raises(ValueError, match="64"): with pytest.raises(ValueError, match="64"):
library_storage_key("private", 1, "deadbeef", ".jpg", uploader_profile_id=1) library_storage_key("private", 1, "deadbeef", ".jpg", uploader_profile_id=1, club_name="X")
def test_library_storage_key_extension() -> None: def test_library_storage_key_extension() -> None:
with pytest.raises(ValueError): with pytest.raises(ValueError):
library_storage_key("private", 1, _HEX64, "../x", uploader_profile_id=1) library_storage_key(
"private", 1, _HEX64, "../x", uploader_profile_id=1, club_name="X"
)
def test_library_storage_key_private_no_club_name_fallback() -> None:
assert (
library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3)
== f"library/verein-c2/u3/image/{_HEX64}.jpg"
)

View File

@ -18,7 +18,7 @@ from tenant_context import TenantContext, get_tenant_context
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) # Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg" _SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg" _SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/club/c1/u1/video/{'c' * 64}.mp4" _SK_PRIV_C = f"library/verein-c1/u1/video/{'c' * 64}.mp4"
@pytest.fixture @pytest.fixture

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.55" APP_VERSION = "0.8.56"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260508049" DB_SCHEMA_VERSION = "20260508049"
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "admin_users": "1.0.0", # GET /api/admin/users
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.10.0", # library/*/image|video|pdf|other/ Unterordner (Medientyp) "media_assets": "1.11.0", # library/{vereins-slug}-c{id}/ statt library/club/c{id}/
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.17.3", # library_storage_key mit mime_type (Medientyp-Ordner) "exercises": "2.17.4", # Übungs-Upload: Vereinsnamen für library-Pfad aus clubs
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.56",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: Vereinsmedien unter library/{aus Name abgeleitet}-c{club_id}/ statt festem library/club/c{id}/; Sonderzeichen/Umlaute zu sicheren Ordnernamen; fehlender Name → verein-c{id}; Governance-Umzug liest aktuellen Vereinsnamen aus DB",
],
},
{ {
"version": "0.8.55", "version": "0.8.55",
"date": "2026-05-07", "date": "2026-05-07",