diff --git a/backend/media_storage.py b/backend/media_storage.py index b38275d..7eac9de 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -63,16 +63,17 @@ def library_storage_key( club_id: Optional[int], sha256_hex: str, ext: str, + *, + uploader_profile_id: Optional[int] = None, ) -> str: """ 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} + - 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() if vis not in ("private", "club", "official"): @@ -99,9 +100,14 @@ def library_storage_key( cid = int(club_id) if cid < 1: raise ValueError("club_id muss eine positive Ganzzahl sein") - if vis == "private": - return f"library/club/c{cid}/private/{blob}" - return f"library/club/c{cid}/shared/{blob}" + if vis == "club": + return f"library/club/c{cid}/{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: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 1c9413e..cf478e7 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2415,13 +2415,23 @@ async def upload_exercise_media( raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id") dedupe_club = int(ex_club) - 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) - LIMIT 1""", - (full_sha, ex_vis, dedupe_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( + """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) + LIMIT 1""", + (full_sha, ex_vis, dedupe_club), + ) existing_asset = cur.fetchone() if existing_asset: @@ -2509,7 +2519,13 @@ async def upload_exercise_media( ) else: 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: raise HTTPException(status_code=400, detail=str(e)) from e dest_path = path_under_media_root(media_root, storage_key) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 5886a24..b5ed2a5 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -332,7 +332,15 @@ def _relocate_asset_file_if_governance_changed( return None ext = Path(old_key.replace("\\", "/")).suffix or ".bin" 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: raise HTTPException(status_code=400, detail=str(e)) from e old_norm = old_key.replace("\\", "/").lstrip("/") @@ -557,13 +565,23 @@ def _ingest_library_media_file( raise HTTPException(status_code=400, detail=str(e)) from e full_sha = hashlib.sha256(raw).hexdigest() - 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) - LIMIT 1""", - (full_sha, vis, next_cid), - ) + 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( + """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) + LIMIT 1""", + (full_sha, vis, next_cid), + ) existing_asset = cur.fetchone() if existing_asset: @@ -606,7 +624,13 @@ def _ingest_library_media_file( media_root = get_effective_media_root(cur) 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: raise HTTPException(status_code=400, detail=str(e)) from e dest_path = path_under_media_root(media_root, storage_key) diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py index 127ec25..1cfe84e 100644 --- a/backend/tests/test_library_storage_key.py +++ b/backend/tests/test_library_storage_key.py @@ -8,25 +8,36 @@ from media_storage import library_storage_key _HEX64 = "a" * 64 -def test_library_storage_key_private_under_club() -> None: - assert library_storage_key("private", 7, _HEX64, ".jpg") == f"library/club/c7/private/{_HEX64}.jpg" +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/{_HEX64}.jpg" + ) -def test_library_storage_key_shared_under_club() -> None: - assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/shared/{_HEX64}.png" +def test_library_storage_key_club_flat_under_club() -> None: + assert library_storage_key("club", 42, _HEX64, ".png") == f"library/club/c42/{_HEX64}.png" 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: - 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: 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: @@ -46,9 +57,9 @@ def test_library_storage_key_invalid_visibility() -> None: def test_library_storage_key_invalid_sha() -> None: 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): - library_storage_key("private", 1, _HEX64, "../x") + library_storage_key("private", 1, _HEX64, "../x", uploader_profile_id=1) diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 070b010..979192d 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/{'a' * 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 diff --git a/backend/version.py b/backend/version.py index e4407b1..950a3be 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.53" +APP_VERSION = "0.8.54" BUILD_DATE = "2026-05-08" -DB_SCHEMA_VERSION = "20260508048" +DB_SCHEMA_VERSION = "20260508049" 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.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", "skills": "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_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.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", "date": "2026-05-08",