diff --git a/backend/media_storage.py b/backend/media_storage.py index 9362f82..b38275d 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -67,10 +67,12 @@ def library_storage_key( """ 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} + 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_id ist bei private/club zwingend (Vereinsordner); bei official nicht genutzt. """ vis = (visibility or "private").strip().lower() if vis not in ("private", "club", "official"): @@ -90,16 +92,16 @@ def library_storage_key( 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") + raise ValueError("Verein (club_id) ist für diese Sichtbarkeit auf der Platte erforderlich") cid = int(club_id) if cid < 1: raise ValueError("club_id muss eine positive Ganzzahl sein") - return f"library/club/c{cid}/{blob}" + if vis == "private": + return f"library/club/c{cid}/private/{blob}" + return f"library/club/c{cid}/shared/{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 9007360..1c9413e 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2397,12 +2397,30 @@ async def upload_exercise_media( media_root = get_effective_media_root(cur) full_sha = hashlib.sha256(raw).hexdigest() + if ex_vis == "official": + dedupe_club: Optional[int] = None + elif ex_vis == "private": + dedupe_club = int(ex_club) if ex_club is not None else tenant.effective_club_id + if dedupe_club is None: + raise HTTPException( + status_code=400, + detail=( + "Private Übungsmedien werden pro Verein gespeichert. Bitte der Übung einen Verein zuordnen " + "oder einen aktiven Verein wählen (X-Active-Club-Id)." + ), + ) + dedupe_club = int(dedupe_club) + else: + if ex_club is None: + 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, ex_club), + (full_sha, ex_vis, dedupe_club), ) existing_asset = cur.fetchone() @@ -2491,7 +2509,7 @@ async def upload_exercise_media( ) else: try: - storage_key = library_storage_key(ex_vis, ex_club, full_sha, ext) + storage_key = library_storage_key(ex_vis, dedupe_club if ex_vis != "official" else None, 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) @@ -2513,7 +2531,7 @@ async def upload_exercise_media( full_sha, file.filename, ex_vis, - ex_club, + dedupe_club, profile_id, storage_key, ), diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 2f885e4..5886a24 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -10,6 +10,7 @@ from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Reques from pydantic import BaseModel, Field, model_validator from club_tenancy import ( + assert_club_member, assert_valid_governance_visibility, club_ids_for_profile_with_roles, is_platform_admin, @@ -293,14 +294,21 @@ def _fetch_filter_uploaders(cur: Any, is_adm: bool, profile_id: int) -> list[dic def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict: - """Nach visibility-Wechsel club_id konsistent setzen (official/private → NULL).""" + """ + Nach visibility-Wechsel club_id konsistent setzen. + + official → club_id NULL. private/club: club_id aus Patch oder bisheriger Zeile beibehalten + (private nutzt club_id für Vereinsordner auf der Platte). + """ eff = dict(patch_fields) if eff.get("visibility") is not None: v = str(eff["visibility"]).strip().lower() - if v in ("official", "private"): + if v == "official": eff["club_id"] = None elif v == "club" and "club_id" not in eff: eff["club_id"] = asset.get("club_id") + elif v == "private" and "club_id" not in eff: + eff["club_id"] = asset.get("club_id") return eff @@ -517,8 +525,24 @@ def _ingest_library_media_file( if club_id_form is None: raise HTTPException(status_code=400, detail="Verein erforderlich für Sichtbarkeit „Verein“") next_cid = int(club_id_form) + elif vis == "private": + if club_id_form is not None: + next_cid = int(club_id_form) + else: + cid = tenant.effective_club_id + next_cid = int(cid) if cid is not None else None + if next_cid is None: + raise HTTPException( + status_code=400, + detail=( + "Private Medien werden pro Verein abgelegt. Bitte aktiven Verein setzen " + "(Header X-Active-Club-Id) oder club_id im Formular übergeben — auch für Plattform-Admins." + ), + ) + if not is_platform_admin(role): + assert_club_member(cur, profile_id, next_cid) - assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid) + assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid if vis == "club" else None) max_b = _upload_limit_bytes(tenant) if len(raw) > max_b: @@ -983,7 +1007,7 @@ def bulk_media_patch( eff = _effective_media_patch_fields(patch_fields, asset) next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower() - next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id") + next_cid = eff.get("club_id", asset.get("club_id")) if "visibility" in patch_fields or "club_id" in patch_fields: assert_valid_governance_visibility( cur, @@ -992,11 +1016,21 @@ def bulk_media_patch( next_vis, int(next_cid) if next_cid is not None else None, ) + if next_vis in ("private", "club") and next_cid is None: + failed.append( + { + "id": asset_id, + "detail": ( + "Für private oder Vereins-Medien wird club_id benötigt (Vereinsordner)." + ), + } + ) + continue 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": + if next_vis in ("club", "private"): 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( @@ -1018,7 +1052,7 @@ def bulk_media_patch( sets.append("visibility = %s") vals.append(str(eff.get("visibility", asset["visibility"])).strip()) sets.append("club_id = %s") - vals.append(eff.get("club_id")) + vals.append(next_cid) if new_sk: sets.append("storage_key = %s") vals.append(new_sk) @@ -1073,7 +1107,7 @@ def patch_media_asset( eff = _effective_media_patch_fields(data, asset) next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower() - next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id") + next_cid = eff.get("club_id", asset.get("club_id")) if "visibility" in data or "club_id" in data: assert_valid_governance_visibility( cur, @@ -1082,11 +1116,19 @@ def patch_media_asset( next_vis, int(next_cid) if next_cid is not None else None, ) + if next_vis in ("private", "club") and next_cid is None: + raise HTTPException( + status_code=400, + detail=( + "Für private oder Vereins-Medien wird club_id benötigt (Vereinsordner). " + "Bitte im PATCH setzen, z. B. bei Wechsel von „offiziell“ zu „privat“." + ), + ) new_sk: Optional[str] = None if "visibility" in data or "club_id" in data: next_club_param: Optional[int] = None - if next_vis == "club": + if next_vis in ("club", "private"): 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( @@ -1108,7 +1150,7 @@ def patch_media_asset( sets.append("visibility = %s") vals.append(str(eff.get("visibility", asset["visibility"])).strip()) sets.append("club_id = %s") - vals.append(eff.get("club_id")) + vals.append(next_cid) if new_sk: sets.append("storage_key = %s") vals.append(new_sk) diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py index be10ab7..127ec25 100644 --- a/backend/tests/test_library_storage_key.py +++ b/backend/tests/test_library_storage_key.py @@ -8,24 +8,29 @@ 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_private_under_club() -> None: + assert library_storage_key("private", 7, _HEX64, ".jpg") == f"library/club/c7/private/{_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_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" + assert library_storage_key(" CLUB ", 1, _HEX64, "pdf") == f"library/club/c1/shared/{_HEX64}.pdf" + + +def test_library_storage_key_private_requires_club() -> None: + with pytest.raises(ValueError, match="Verein"): + library_storage_key("private", None, _HEX64, ".jpg") def test_library_storage_key_club_requires_id() -> None: - with pytest.raises(ValueError, match="club_id"): + with pytest.raises(ValueError, match="Verein"): library_storage_key("club", None, _HEX64, ".jpg") @@ -36,14 +41,14 @@ def test_library_storage_key_club_id_positive() -> None: def test_library_storage_key_invalid_visibility() -> None: with pytest.raises(ValueError, match="Sichtbarkeit"): - library_storage_key("public", None, _HEX64, ".jpg") + library_storage_key("public", 1, _HEX64, ".jpg") def test_library_storage_key_invalid_sha() -> None: with pytest.raises(ValueError, match="64"): - library_storage_key("private", None, "deadbeef", ".jpg") + library_storage_key("private", 1, "deadbeef", ".jpg") def test_library_storage_key_extension_sanitized() -> None: with pytest.raises(ValueError): - library_storage_key("private", None, _HEX64, "../x") + library_storage_key("private", 1, _HEX64, "../x") diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 5887ddb..070b010 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/private/{'c' * 64}.mp4" +_SK_PRIV_C = f"library/club/c1/private/{'c' * 64}.mp4" @pytest.fixture @@ -331,7 +331,7 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None: { "id": 5, "visibility": "private", - "club_id": None, + "club_id": 1, "uploaded_by_profile_id": 1, "lifecycle_state": "trash_soft", "storage_key": _SK_PRIV_C, diff --git a/backend/version.py b/backend/version.py index 90a4d24..e4407b1 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.52" +APP_VERSION = "0.8.53" BUILD_DATE = "2026-05-08" -DB_SCHEMA_VERSION = "20260508047" +DB_SCHEMA_VERSION = "20260508048" 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.7.0", # library/* paths; Relocate bei Governance-PATCH + "media_assets": "1.8.0", # private unter library/club/c*/private; club → …/shared; Vereinskontext Pflicht "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.17.0", # Medien-Upload storage_key library/private|club|official + "exercises": "2.17.1", # Übungsmedien: dedupe_club für private aus Übung oder X-Active-Club-Id "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,11 +29,18 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.53", + "date": "2026-05-08", + "changes": [ + "Medienablage vereinsbezogen: private → library/club/c{id}/private, Vereinssichtbarkeit → …/shared, official unverändert; private Archiv-Upload: club_id oder X-Active-Club-Id; DB club_id bei private gesetzt; PATCH/Bulk: club_id für private nicht mehr blind auf NULL", + ], + }, { "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", + "Neue lokale media_assets: hierarchische library/* Pfade; Dedupe nach sha+visibility+club_id; bei PATCH/Bulk-Patch Sichtbarkeit/Verein: Datei umziehen, exercise_media.file_path aktualisieren (Details siehe 0.8.53)", ], }, {