fix(media): Vereinsordner für private/shared; kein library/private mehr
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 23s
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 8s
Test Suite / playwright-tests (push) Successful in 23s
- library/club/c{id}/private/* und …/shared/* (club visibility)
- Private Archiv-Upload: effective_club_id oder club_id Form; Plattform braucht Header
- media_assets.club_id bei private gesetzt; Dedupe pro Verein
- Übungs-Upload: dedupe_club für private aus exercise.club_id oder Mandant
- PATCH: club_id für private erhalten; Relocate inkl. private
- version 0.8.53
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
4fb77d6927
commit
3f0d02edab
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user