feat(media): library/* Speicherpfade nach Sichtbarkeit und Verein
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

- media_storage.library_storage_key + relocate_local_media_file
- Übungs- und Archiv-Upload nutzen library/private|club/c*|official
- PATCH/Bulk-Patch: bei visibility/club_id Datei umziehen, storage_key + exercise_media.file_path
- pytest: test_library_storage_key; Mocks angepasst
- version 0.8.52 / media_assets 1.7.0 / exercises 2.17.0

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-05-08 09:47:16 +02:00
parent b8842cf98e
commit 4fb77d6927
6 changed files with 227 additions and 15 deletions

View File

@ -3,6 +3,7 @@ from __future__ import annotations
import os import os
import re import re
import shutil
from pathlib import Path from pathlib import Path
from typing import Any, Optional from typing import Any, Optional
@ -55,3 +56,78 @@ def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
except ValueError: except ValueError:
return None return None
return p return p
def library_storage_key(
visibility: str,
club_id: Optional[int],
sha256_hex: str,
ext: str,
) -> str:
"""
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
Layout (Mandantentrennung, globale Medien getrennt):
- private library/private/{sha256}{ext}
- club library/club/c{club_id}/{sha256}{ext}
- official library/official/{sha256}{ext}
"""
vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"):
raise ValueError(f"Ungültige Sichtbarkeit für Speicherpfad: {visibility!r}")
sha = (sha256_hex or "").strip().lower()
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
raise ValueError("sha256_hex muss 64 Hex-Zeichen sein")
e = (ext or "").strip()
if not e:
e = ".bin"
if not e.startswith("."):
e = "." + e
e = e[:16]
if ".." in e or "/" in e or "\\" in e or "\x00" in e:
raise ValueError("Ungültige Dateiendung")
blob = f"{sha}{e}"
if vis == "private":
return f"library/private/{blob}"
if vis == "official":
return f"library/official/{blob}"
if club_id is None:
raise ValueError("club_id ist für Sichtbarkeit „Verein“ erforderlich")
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
return f"library/club/c{cid}/{blob}"
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:
"""
Physisches Verschieben bei geändertem library_*-Pfad (z. B. nach PATCH visibility/club_id).
- Kein Op, wenn Quelle fehlt aber Ziel bereits existiert (idempotent).
- Erwartet storage_backend=local; Aufrufer prüft das.
"""
if (old_storage_key or "").replace("\\", "/").lstrip("/") == (new_storage_key or "").replace(
"\\", "/"
).lstrip("/"):
return
old_p = path_under_media_root(media_root, old_storage_key)
new_p = path_under_media_root(media_root, new_storage_key)
if old_p is None or new_p is None:
raise ValueError("Ungültiger Speicherpfad (Path-Traversal)")
if new_p.is_file():
if not old_p.is_file():
return
if old_p.resolve() == new_p.resolve():
return
raise FileExistsError(f"Zieldatei existiert bereits: {new_storage_key}")
if not old_p.is_file():
raise FileNotFoundError(f"Medien-Quelldatei nicht gefunden: {old_storage_key}")
new_p.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_p), str(new_p))

View File

@ -28,7 +28,7 @@ from club_tenancy import (
library_content_visible_to_profile, library_content_visible_to_profile,
) )
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
from media_storage import get_effective_media_root, path_under_media_root from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -2490,7 +2490,10 @@ async def upload_exercise_media(
}, },
) )
else: else:
storage_key = f"exercises/{full_sha}{ext}" try:
storage_key = library_storage_key(ex_vis, ex_club, full_sha, ext)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
dest_path = path_under_media_root(media_root, storage_key) dest_path = path_under_media_root(media_root, storage_key)
if dest_path is None: if dest_path is None:
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")

View File

@ -33,7 +33,7 @@ from media_lifecycle import (
transition_to_trash_hidden, transition_to_trash_hidden,
transition_to_trash_soft, transition_to_trash_soft,
) )
from media_storage import get_effective_media_root, path_under_media_root from media_storage import get_effective_media_root, library_storage_key, path_under_media_root, relocate_local_media_file
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type
@ -304,6 +304,49 @@ def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
return eff return eff
def _relocate_asset_file_if_governance_changed(
cur: Any,
media_root: Path,
asset_id: int,
asset: dict,
next_vis: str,
next_club_id: Optional[int],
) -> Optional[str]:
"""
Passt bei local-Assets den Dateipfad an, wenn sich Sichtbarkeit/Verein ändert.
Aktualisiert exercise_media.file_path. Gibt neuen storage_key oder None zurück.
"""
if (asset.get("storage_backend") or "local") != "local":
return None
old_key = (asset.get("storage_key") or "").strip()
sha = (asset.get("sha256") or "").strip().lower()
if not old_key or len(sha) != 64:
return None
ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
try:
new_key = library_storage_key(next_vis, next_club_id, sha, ext)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
old_norm = old_key.replace("\\", "/").lstrip("/")
if new_key == old_norm:
return None
try:
relocate_local_media_file(media_root, old_key, new_key)
except FileNotFoundError as e:
raise HTTPException(status_code=500, detail=f"Mediendatei fehlt auf der Platte: {e}") from e
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except (ValueError, OSError) as e:
raise HTTPException(status_code=500, detail=str(e)) from e
db_path = f"/media/{new_key}"
cur.execute(
"UPDATE exercise_media SET file_path = %s WHERE media_asset_id = %s",
(db_path, asset_id),
)
return new_key
def _lifecycle_where_sql(lifecycle: str) -> str: def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower() lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS: if lc not in _LIFECYCLE_LIST_FILTERS:
@ -538,7 +581,10 @@ def _ingest_library_media_file(
ext = ".mov" ext = ".mov"
media_root = get_effective_media_root(cur) media_root = get_effective_media_root(cur)
storage_key = f"exercises/{full_sha}{ext}" try:
storage_key = library_storage_key(vis, next_cid, full_sha, ext)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
dest_path = path_under_media_root(media_root, storage_key) dest_path = path_under_media_root(media_root, storage_key)
if dest_path is None: if dest_path is None:
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad") raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
@ -915,7 +961,7 @@ def bulk_media_patch(
try: try:
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename copyright_notice, original_filename, sha256, storage_key, storage_backend
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )
@ -947,6 +993,16 @@ def bulk_media_patch(
int(next_cid) if next_cid is not None else None, int(next_cid) if next_cid is not None else None,
) )
new_sk: Optional[str] = None
if "visibility" in patch_fields or "club_id" in patch_fields:
next_club_param: Optional[int] = None
if next_vis == "club":
next_club_param = int(next_cid) if next_cid is not None else None
media_root = get_effective_media_root(cur)
new_sk = _relocate_asset_file_if_governance_changed(
cur, media_root, asset_id, asset, next_vis, next_club_param
)
sets: list[str] = [] sets: list[str] = []
vals: list[Any] = [] vals: list[Any] = []
if "copyright_notice" in patch_fields: if "copyright_notice" in patch_fields:
@ -963,6 +1019,9 @@ def bulk_media_patch(
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s") sets.append("club_id = %s")
vals.append(eff.get("club_id")) vals.append(eff.get("club_id"))
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if not sets: if not sets:
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"}) failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
continue continue
@ -996,7 +1055,7 @@ def patch_media_asset(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename copyright_notice, original_filename, sha256, storage_key, storage_backend
FROM media_assets WHERE id = %s""", FROM media_assets WHERE id = %s""",
(asset_id,), (asset_id,),
) )
@ -1024,6 +1083,16 @@ def patch_media_asset(
int(next_cid) if next_cid is not None else None, int(next_cid) if next_cid is not None else None,
) )
new_sk: Optional[str] = None
if "visibility" in data or "club_id" in data:
next_club_param: Optional[int] = None
if next_vis == "club":
next_club_param = int(next_cid) if next_cid is not None else None
media_root = get_effective_media_root(cur)
new_sk = _relocate_asset_file_if_governance_changed(
cur, media_root, asset_id, asset, next_vis, next_club_param
)
sets: list[str] = [] sets: list[str] = []
vals: list[Any] = [] vals: list[Any] = []
if "copyright_notice" in data: if "copyright_notice" in data:
@ -1040,6 +1109,9 @@ def patch_media_asset(
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s") sets.append("club_id = %s")
vals.append(eff.get("club_id")) vals.append(eff.get("club_id"))
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if sets: if sets:
sets.append("updated_at = NOW()") sets.append("updated_at = NOW()")
vals.append(asset_id) vals.append(asset_id)

View File

@ -0,0 +1,49 @@
"""library_storage_key: Mandantenpfade unter MEDIA_ROOT."""
from __future__ import annotations
import pytest
from media_storage import library_storage_key
_HEX64 = "a" * 64
def test_library_storage_key_private() -> None:
assert library_storage_key("private", None, _HEX64, ".jpg") == f"library/private/{_HEX64}.jpg"
def test_library_storage_key_official() -> None:
assert library_storage_key("official", None, _HEX64, ".mp4") == f"library/official/{_HEX64}.mp4"
def test_library_storage_key_club() -> None:
assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png"
def test_library_storage_key_normalizes_visibility() -> None:
assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/{_HEX64}.pdf"
def test_library_storage_key_club_requires_id() -> None:
with pytest.raises(ValueError, match="club_id"):
library_storage_key("club", None, _HEX64, ".jpg")
def test_library_storage_key_club_id_positive() -> None:
with pytest.raises(ValueError, match="positiv"):
library_storage_key("club", 0, _HEX64, ".jpg")
def test_library_storage_key_invalid_visibility() -> None:
with pytest.raises(ValueError, match="Sichtbarkeit"):
library_storage_key("public", None, _HEX64, ".jpg")
def test_library_storage_key_invalid_sha() -> None:
with pytest.raises(ValueError, match="64"):
library_storage_key("private", None, "deadbeef", ".jpg")
def test_library_storage_key_extension_sanitized() -> None:
with pytest.raises(ValueError):
library_storage_key("private", None, _HEX64, "../x")

View File

@ -15,6 +15,11 @@ from auth import require_auth
from main import app from main import app
from tenant_context import TenantContext, get_tenant_context 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/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/private/{'c' * 64}.mp4"
@pytest.fixture @pytest.fixture
def client() -> TestClient: def client() -> TestClient:
@ -136,7 +141,7 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
"club_id": None, "club_id": None,
"uploaded_by_profile_id": 1, "uploaded_by_profile_id": 1,
"lifecycle_state": "active", "lifecycle_state": "active",
"storage_key": "exercises/x.jpg", "storage_key": _SK_OFF_A,
}, },
{"id": 1}, {"id": 1},
] ]
@ -175,7 +180,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"id": 99, "id": 99,
"exercise_id": 3, "exercise_id": 3,
"media_type": "image", "media_type": "image",
"file_path": "/media/exercises/h.jpg", "file_path": f"/media/{_SK_OFF_B}",
"file_size": 10, "file_size": 10,
"mime_type": "image/jpeg", "mime_type": "image/jpeg",
"original_filename": "h.jpg", "original_filename": "h.jpg",
@ -202,7 +207,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"club_id": None, "club_id": None,
"uploaded_by_profile_id": 1, "uploaded_by_profile_id": 1,
"lifecycle_state": "active", "lifecycle_state": "active",
"storage_key": "exercises/h.jpg", "storage_key": _SK_OFF_B,
}, },
None, None,
inserted, inserted,
@ -329,7 +334,7 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
"club_id": None, "club_id": None,
"uploaded_by_profile_id": 1, "uploaded_by_profile_id": 1,
"lifecycle_state": "trash_soft", "lifecycle_state": "trash_soft",
"storage_key": "exercises/a.mp4", "storage_key": _SK_PRIV_C,
"storage_backend": "local", "storage_backend": "local",
"trash_soft_at": None, "trash_soft_at": None,
"trash_hidden_at": None, "trash_hidden_at": None,

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.51" APP_VERSION = "0.8.52"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260507046" DB_SCHEMA_VERSION = "20260508047"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@ -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.5.1", # usage: training_unit_exercises optional (Schema ohne planning-Tabelle) "media_assets": "1.7.0", # library/* paths; Relocate bei Governance-PATCH
"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.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata) "exercises": "2.17.0", # Medien-Upload storage_key library/private|club|official
"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.52",
"date": "2026-05-08",
"changes": [
"Neue lokale media_assets: storage_key unter library/private, library/club/c{id}, library/official (SHA256+Ext); Dedupe unverändert;bei PATCH/Bulk-Patch Sichtbarkeit/Verein: Datei wird mit umgezogen, exercise_media.file_path aktualisiert",
],
},
{ {
"version": "0.8.51", "version": "0.8.51",
"date": "2026-05-07", "date": "2026-05-07",