MediaPfad extern, Upload Manager Bug Fixes #23
|
|
@ -63,16 +63,17 @@ def library_storage_key(
|
||||||
club_id: Optional[int],
|
club_id: Optional[int],
|
||||||
sha256_hex: str,
|
sha256_hex: str,
|
||||||
ext: str,
|
ext: str,
|
||||||
|
*,
|
||||||
|
uploader_profile_id: Optional[int] = None,
|
||||||
) -> str:
|
) -> str:
|
||||||
"""
|
"""
|
||||||
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
|
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
|
||||||
|
|
||||||
Layout (nach Verein gegliedert, globale Medien getrennt):
|
|
||||||
- private → library/club/c{club_id}/private/{sha256}{ext}
|
|
||||||
- club (Vereinssichtbarkeit) → library/club/c{club_id}/shared/{sha256}{ext}
|
|
||||||
- official → library/official/{sha256}{ext}
|
- official → library/official/{sha256}{ext}
|
||||||
|
- club (vereinsgeteilt) → library/club/c{club_id}/{sha256}{ext}
|
||||||
|
- private (nur Hochlader, Governance unverändert) → library/club/c{club_id}/u{profile}/{sha256}{ext}
|
||||||
|
|
||||||
club_id ist bei private/club zwingend (Vereinsordner); bei official nicht genutzt.
|
Kein Ordnername „private“ auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
|
||||||
"""
|
"""
|
||||||
vis = (visibility or "private").strip().lower()
|
vis = (visibility or "private").strip().lower()
|
||||||
if vis not in ("private", "club", "official"):
|
if vis not in ("private", "club", "official"):
|
||||||
|
|
@ -99,9 +100,14 @@ 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")
|
||||||
if vis == "private":
|
if vis == "club":
|
||||||
return f"library/club/c{cid}/private/{blob}"
|
return f"library/club/c{cid}/{blob}"
|
||||||
return f"library/club/c{cid}/shared/{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}"
|
||||||
|
|
||||||
|
|
||||||
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:
|
||||||
|
|
|
||||||
|
|
@ -2415,6 +2415,16 @@ async def upload_exercise_media(
|
||||||
raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id")
|
raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id")
|
||||||
dedupe_club = int(ex_club)
|
dedupe_club = int(ex_club)
|
||||||
|
|
||||||
|
if ex_vis == "private":
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
||||||
|
WHERE sha256 = %s AND lower(trim(visibility)) = 'private'
|
||||||
|
AND (club_id IS NOT DISTINCT FROM %s)
|
||||||
|
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
|
||||||
|
LIMIT 1""",
|
||||||
|
(full_sha, dedupe_club, profile_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
||||||
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||||
|
|
@ -2509,7 +2519,13 @@ async def upload_exercise_media(
|
||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
storage_key = library_storage_key(ex_vis, dedupe_club if ex_vis != "official" else None, full_sha, ext)
|
storage_key = library_storage_key(
|
||||||
|
ex_vis,
|
||||||
|
dedupe_club if ex_vis != "official" else None,
|
||||||
|
full_sha,
|
||||||
|
ext,
|
||||||
|
uploader_profile_id=profile_id if ex_vis == "private" else None,
|
||||||
|
)
|
||||||
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
|
||||||
dest_path = path_under_media_root(media_root, storage_key)
|
dest_path = path_under_media_root(media_root, storage_key)
|
||||||
|
|
|
||||||
|
|
@ -332,7 +332,15 @@ def _relocate_asset_file_if_governance_changed(
|
||||||
return None
|
return None
|
||||||
ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
|
ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
|
||||||
try:
|
try:
|
||||||
new_key = library_storage_key(next_vis, next_club_id, sha, ext)
|
up = asset.get("uploaded_by_profile_id")
|
||||||
|
up_i = int(up) if up is not None else None
|
||||||
|
new_key = library_storage_key(
|
||||||
|
next_vis,
|
||||||
|
next_club_id if next_vis != "official" else None,
|
||||||
|
sha,
|
||||||
|
ext,
|
||||||
|
uploader_profile_id=up_i if next_vis == "private" else None,
|
||||||
|
)
|
||||||
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
|
||||||
old_norm = old_key.replace("\\", "/").lstrip("/")
|
old_norm = old_key.replace("\\", "/").lstrip("/")
|
||||||
|
|
@ -557,6 +565,16 @@ def _ingest_library_media_file(
|
||||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||||
|
|
||||||
full_sha = hashlib.sha256(raw).hexdigest()
|
full_sha = hashlib.sha256(raw).hexdigest()
|
||||||
|
if vis == "private":
|
||||||
|
cur.execute(
|
||||||
|
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
||||||
|
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||||
|
AND (club_id IS NOT DISTINCT FROM %s)
|
||||||
|
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
|
||||||
|
LIMIT 1""",
|
||||||
|
(full_sha, vis, next_cid, profile_id),
|
||||||
|
)
|
||||||
|
else:
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
|
||||||
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||||
|
|
@ -606,7 +624,13 @@ def _ingest_library_media_file(
|
||||||
|
|
||||||
media_root = get_effective_media_root(cur)
|
media_root = get_effective_media_root(cur)
|
||||||
try:
|
try:
|
||||||
storage_key = library_storage_key(vis, next_cid, full_sha, ext)
|
storage_key = library_storage_key(
|
||||||
|
vis,
|
||||||
|
next_cid,
|
||||||
|
full_sha,
|
||||||
|
ext,
|
||||||
|
uploader_profile_id=profile_id if vis == "private" else None,
|
||||||
|
)
|
||||||
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
|
||||||
dest_path = path_under_media_root(media_root, storage_key)
|
dest_path = path_under_media_root(media_root, storage_key)
|
||||||
|
|
|
||||||
|
|
@ -8,25 +8,36 @@ from media_storage import library_storage_key
|
||||||
_HEX64 = "a" * 64
|
_HEX64 = "a" * 64
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_private_under_club() -> None:
|
def test_library_storage_key_private_is_uploader_under_club() -> None:
|
||||||
assert library_storage_key("private", 7, _HEX64, ".jpg") == f"library/club/c7/private/{_HEX64}.jpg"
|
assert (
|
||||||
|
library_storage_key("private", 7, _HEX64, ".jpg", uploader_profile_id=99)
|
||||||
|
== f"library/club/c7/u99/{_HEX64}.jpg"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_shared_under_club() -> None:
|
def test_library_storage_key_club_flat_under_club() -> None:
|
||||||
assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/shared/{_HEX64}.png"
|
assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png"
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_official() -> None:
|
def test_library_storage_key_official() -> None:
|
||||||
assert library_storage_key("official", None, _HEX64, ".mp4") == f"library/official/{_HEX64}.mp4"
|
assert (
|
||||||
|
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
|
||||||
|
== f"library/official/{_HEX64}.mp4"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_normalizes_visibility() -> None:
|
def test_library_storage_key_normalizes_visibility() -> None:
|
||||||
assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/shared/{_HEX64}.pdf"
|
assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/{_HEX64}.pdf"
|
||||||
|
|
||||||
|
|
||||||
|
def test_library_storage_key_private_requires_uploader() -> None:
|
||||||
|
with pytest.raises(ValueError, match="uploader_profile_id"):
|
||||||
|
library_storage_key("private", 1, _HEX64, ".jpg")
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_private_requires_club() -> None:
|
def test_library_storage_key_private_requires_club() -> None:
|
||||||
with pytest.raises(ValueError, match="Verein"):
|
with pytest.raises(ValueError, match="Verein"):
|
||||||
library_storage_key("private", None, _HEX64, ".jpg")
|
library_storage_key("private", None, _HEX64, ".jpg", uploader_profile_id=1)
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_club_requires_id() -> None:
|
def test_library_storage_key_club_requires_id() -> None:
|
||||||
|
|
@ -46,9 +57,9 @@ def test_library_storage_key_invalid_visibility() -> None:
|
||||||
|
|
||||||
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")
|
library_storage_key("private", 1, "deadbeef", ".jpg", uploader_profile_id=1)
|
||||||
|
|
||||||
|
|
||||||
def test_library_storage_key_extension_sanitized() -> None:
|
def test_library_storage_key_extension() -> None:
|
||||||
with pytest.raises(ValueError):
|
with pytest.raises(ValueError):
|
||||||
library_storage_key("private", 1, _HEX64, "../x")
|
library_storage_key("private", 1, _HEX64, "../x", uploader_profile_id=1)
|
||||||
|
|
|
||||||
|
|
@ -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/{'a' * 64}.jpg"
|
_SK_OFF_A = f"library/official/{'a' * 64}.jpg"
|
||||||
_SK_OFF_B = f"library/official/{'b' * 64}.jpg"
|
_SK_OFF_B = f"library/official/{'b' * 64}.jpg"
|
||||||
_SK_PRIV_C = f"library/club/c1/private/{'c' * 64}.mp4"
|
_SK_PRIV_C = f"library/club/c1/u1/{'c' * 64}.mp4"
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
# Shinkan Jinkendo Version Information
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.53"
|
APP_VERSION = "0.8.54"
|
||||||
BUILD_DATE = "2026-05-08"
|
BUILD_DATE = "2026-05-08"
|
||||||
DB_SCHEMA_VERSION = "20260508048"
|
DB_SCHEMA_VERSION = "20260508049"
|
||||||
|
|
||||||
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.8.0", # private unter library/club/c*/private; club → …/shared; Vereinskontext Pflicht
|
"media_assets": "1.9.0", # club: flach unter library/club/c*; private: …/u{profile}/; Dedupe private + uploader
|
||||||
"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.1", # Übungsmedien: dedupe_club für private aus Übung oder X-Active-Club-Id
|
"exercises": "2.17.2", # Dedupe private + uploaded_by; storage_key mit uploader_profile_id
|
||||||
"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.54",
|
||||||
|
"date": "2026-05-08",
|
||||||
|
"changes": [
|
||||||
|
"Medienpfade: vereinsgeteilt (club) direkt unter library/club/c{id}/; private unter library/club/c{id}/u{profile}/ (kein Ordner „private“/„shared“); Dedupe private inkl. uploaded_by_profile_id",
|
||||||
|
],
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"version": "0.8.53",
|
"version": "0.8.53",
|
||||||
"date": "2026-05-08",
|
"date": "2026-05-08",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user