MediaPfad extern, Upload Manager Bug Fixes #23
|
|
@ -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:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user