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 re
import shutil
from pathlib import Path
from typing import Any, Optional
@ -55,3 +56,78 @@ def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
except ValueError:
return None
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,
)
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__)
@ -2490,7 +2490,10 @@ async def upload_exercise_media(
},
)
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)
if dest_path is None:
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_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 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
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:
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
@ -538,7 +581,10 @@ def _ingest_library_media_file(
ext = ".mov"
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)
if dest_path is None:
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
@ -915,7 +961,7 @@ def bulk_media_patch(
try:
cur.execute(
"""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""",
(asset_id,),
)
@ -947,6 +993,16 @@ def bulk_media_patch(
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] = []
vals: list[Any] = []
if "copyright_notice" in patch_fields:
@ -963,6 +1019,9 @@ def bulk_media_patch(
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if not sets:
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
continue
@ -996,7 +1055,7 @@ def patch_media_asset(
cur = get_cursor(conn)
cur.execute(
"""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""",
(asset_id,),
)
@ -1024,6 +1083,16 @@ def patch_media_asset(
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] = []
vals: list[Any] = []
if "copyright_notice" in data:
@ -1040,6 +1109,9 @@ def patch_media_asset(
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if sets:
sets.append("updated_at = NOW()")
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 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
def client() -> TestClient:
@ -136,7 +141,7 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/x.jpg",
"storage_key": _SK_OFF_A,
},
{"id": 1},
]
@ -175,7 +180,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"id": 99,
"exercise_id": 3,
"media_type": "image",
"file_path": "/media/exercises/h.jpg",
"file_path": f"/media/{_SK_OFF_B}",
"file_size": 10,
"mime_type": "image/jpeg",
"original_filename": "h.jpg",
@ -202,7 +207,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/h.jpg",
"storage_key": _SK_OFF_B,
},
None,
inserted,
@ -329,7 +334,7 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "trash_soft",
"storage_key": "exercises/a.mp4",
"storage_key": _SK_PRIV_C,
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.51"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507046"
APP_VERSION = "0.8.52"
BUILD_DATE = "2026-05-08"
DB_SCHEMA_VERSION = "20260508047"
MODULE_VERSIONS = {
"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)
"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.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",
"skills": "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_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.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",
"date": "2026-05-07",