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
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:
parent
b8842cf98e
commit
4fb77d6927
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
49
backend/tests/test_library_storage_key.py
Normal file
49
backend/tests/test_library_storage_key.py
Normal 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")
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user