From 01636b5baf8ac60bfb710106418c6a5740af1523 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 10:06:05 +0200 Subject: [PATCH] Medien: Vereinsordner aus Namen statt library/club/c{id} Co-authored-by: Cursor --- backend/media_storage.py | 52 ++++++++++++++++++++-- backend/routers/exercises.py | 6 +++ backend/routers/media_assets.py | 18 ++++++++ backend/tests/test_library_storage_key.py | 50 +++++++++++++++------ backend/tests/test_media_assets_archive.py | 2 +- backend/version.py | 13 ++++-- 6 files changed, 120 insertions(+), 21 deletions(-) diff --git a/backend/media_storage.py b/backend/media_storage.py index 4fa5c9a..87d84c3 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -2,10 +2,24 @@ from __future__ import annotations import os +import re import shutil +import unicodedata from pathlib import Path from typing import Any, Optional +_CLUB_SLUG_TRANS = str.maketrans( + { + "ä": "ae", + "ö": "oe", + "ü": "ue", + "ß": "ss", + "Ä": "ae", + "Ö": "oe", + "Ü": "ue", + } +) + def _default_media_root() -> Path: 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" +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]: """Gibt absoluten Pfad zurück oder None bei Path-Traversal.""" key = (storage_key or "").strip().replace("\\", "/").lstrip("/") @@ -103,13 +143,16 @@ def library_storage_key( *, uploader_profile_id: Optional[int] = None, mime_type: Optional[str] = None, + club_name: Optional[str] = None, ) -> str: """ Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets. - official → library/official/{kind}/{sha256}{ext} - - club (vereinsgeteilt) → library/club/c{club_id}/{kind}/{sha256}{ext} - - private (nur Hochlader, Governance unverändert) → library/club/c{club_id}/u{profile}/{kind}/{sha256}{ext} + - club (vereinsgeteilt) → library/{vereins-segment}/{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. @@ -141,14 +184,15 @@ def library_storage_key( cid = int(club_id) if cid < 1: raise ValueError("club_id muss eine positive Ganzzahl sein") + club_seg = library_club_path_segment(cid, club_name) if vis == "club": - return f"library/club/c{cid}/{blob}" + return f"library/{club_seg}/{blob}" if uploader_profile_id is None: raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)") up = int(uploader_profile_id) if up < 1: 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: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 48bec7c..7e2a620 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2518,6 +2518,11 @@ async def upload_exercise_media( }, ) 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: storage_key = library_storage_key( ex_vis, @@ -2526,6 +2531,7 @@ async def upload_exercise_media( ext, uploader_profile_id=profile_id if ex_vis == "private" else None, mime_type=mime, + club_name=club_nm, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 157a78e..bd5e504 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -312,6 +312,15 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict: 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( cur: Any, media_root: Path, @@ -334,6 +343,9 @@ def _relocate_asset_file_if_governance_changed( try: up = asset.get("uploaded_by_profile_id") 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( next_vis, next_club_id if next_vis != "official" else None, @@ -341,6 +353,7 @@ def _relocate_asset_file_if_governance_changed( ext, uploader_profile_id=up_i if next_vis == "private" else None, mime_type=str(asset.get("mime_type") or "") or None, + club_name=club_nm, ) except ValueError as 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": 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) try: storage_key = library_storage_key( @@ -632,6 +649,7 @@ def _ingest_library_media_file( ext, uploader_profile_id=profile_id if vis == "private" else None, mime_type=mime, + club_name=club_nm, ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) from e diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py index 97d4d98..57004d9 100644 --- a/backend/tests/test_library_storage_key.py +++ b/backend/tests/test_library_storage_key.py @@ -3,7 +3,7 @@ from __future__ import annotations 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 @@ -13,17 +13,32 @@ def test_library_media_kind_dir_mime_overrides_ext() -> None: 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: assert ( - library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99) - == f"library/club/c7/u99/image/{_HEX64}.jpg" + library_storage_key( + "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: assert ( - library_storage_key("club", 42, _HEX64, ".png") - == f"library/club/c42/image/{_HEX64}.png" + library_storage_key("club", 42, _HEX64, ".png", club_name="Demo-Verein") + == 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: assert ( - library_storage_key(" CLUB ", 1, _HEX64, "pdf") - == f"library/club/c1/pdf/{_HEX64}.pdf" + library_storage_key(" CLUB ", 1, _HEX64, "pdf", club_name="Alpha") + == 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: 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: @@ -60,24 +75,33 @@ def test_library_storage_key_private_requires_club() -> None: def test_library_storage_key_club_requires_id() -> None: 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: 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: 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: 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: 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" + ) diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index d721497..36c9d9f 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -18,7 +18,7 @@ from tenant_context import TenantContext, get_tenant_context # Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256) _SK_OFF_A = f"library/official/image/{'a' * 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 diff --git a/backend/version.py b/backend/version.py index b087ca6..b9c51d4 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.55" +APP_VERSION = "0.8.56" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260508049" @@ -13,11 +13,11 @@ MODULE_VERSIONS = { "club_join_requests": "1.0.1", # Depends(get_tenant_context) "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) - "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", "skills": "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_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-07",