diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py index 570b4f4..e2d273a 100644 --- a/backend/media_lifecycle.py +++ b/backend/media_lifecycle.py @@ -27,20 +27,26 @@ HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None: """ Papierkorb Stufe 2 / Recovery / Reaktivierung — nicht für trash_soft (siehe assert_can_trash_soft). - §5.2: official nur Plattform-Admin; club Vereinsorga; privat nur Uploader. + §5.2: official nur Superadmin; club Vereinsorga; privat nur Uploader (Plattform-Admin sonst wie bisher). """ profile_id = tenant.profile_id role = (tenant.global_role or "").strip().lower() + vis = (asset.get("visibility") or "private").strip().lower() + if vis == "official": + if not is_superadmin(role): + raise HTTPException( + status_code=403, + detail="Offizielle Medien dürfen nur von Superadmins geändert oder gelöscht werden", + ) + return + if is_platform_admin(role): return - vis = (asset.get("visibility") or "private").strip().lower() uid = asset.get("uploaded_by_profile_id") if vis == "private": if uid is not None and int(uid) == int(profile_id): return raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") - if vis == "official": - raise HTTPException(status_code=403, detail="Nur Plattform-Admin") if vis == "club": cid = asset.get("club_id") if cid is None: @@ -54,15 +60,20 @@ def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None: """ Aktiv → Papierkorb (Stufe 1). Trainer: nur eigene private Uploads. - Vereinsmedien: Vereinsorga; official: Plattform-Admin; Superadmin: immer. + Vereinsmedien: Vereinsorga; official: nur Superadmin; Plattform-Admin: sonst wie Verein/privat. """ role_raw = tenant.global_role role = (role_raw or "").strip().lower() if is_superadmin(role): return + vis = (asset.get("visibility") or "private").strip().lower() + if vis == "official": + raise HTTPException( + status_code=403, + detail="Offizielle Medien dürfen nur von Superadmins in den Papierkorb gelegt werden", + ) if is_platform_admin(role): return - vis = (asset.get("visibility") or "private").strip().lower() uid = asset.get("uploaded_by_profile_id") cid = asset.get("club_id") pid = int(tenant.profile_id) @@ -80,8 +91,6 @@ def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None: status_code=403, detail="Nur Vereinsorganisation darf Vereinsmedien in den Papierkorb legen", ) - if vis == "official": - raise HTTPException(status_code=403, detail="Nur Plattform-Admin") raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index fae721d..7efd304 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -427,25 +427,25 @@ def _item_permissions(row: dict, tenant: TenantContext, admin_club_ids: set[int] is_owner = uid is not None and int(uid) == pid club_mgr = plat or (cid is not None and int(cid) in admin_club_ids) - edit_metadata = ( - sup - or (vis == "official" and plat) - or (vis == "club" and club_mgr) - or (vis == "private" and is_owner) - ) - - trash_soft = lc == "active" and ( - sup or plat or (vis == "private" and is_owner) or (vis == "club" and club_mgr) - ) - if vis == "official" and not (sup or plat): - trash_soft = False - - can_manage_adv = ( - sup - or plat - or (vis == "private" and is_owner) - or (vis == "club" and club_mgr) - ) + if vis == "official": + edit_metadata = sup + trash_soft = lc == "active" and sup + can_manage_adv = sup + else: + edit_metadata = ( + sup + or (vis == "club" and club_mgr) + or (vis == "private" and is_owner) + ) + trash_soft = lc == "active" and ( + sup or plat or (vis == "private" and is_owner) or (vis == "club" and club_mgr) + ) + can_manage_adv = ( + sup + or plat + or (vis == "private" and is_owner) + or (vis == "club" and club_mgr) + ) trash_hidden = lc in ("active", "trash_soft") and can_manage_adv recover_from_hidden = lc == "trash_hidden" and can_manage_adv diff --git a/backend/version.py b/backend/version.py index 95ead31..9b81e42 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.57" +APP_VERSION = "0.8.58" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260508049" @@ -13,7 +13,7 @@ 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.12.0", # Privat: Plattform-Admin muss club_id; private Ablage wie Verein (.u{pid} im Dateinamen) + "media_assets": "1.12.1", # official: nur Superadmin Lifecycle/PATCH; UI Lesemodus; Superadmin Upload-Verein = aktiv "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.58", + "date": "2026-05-07", + "changes": [ + "Medienbibliothek offiziell: Ändern/Lifecycle nur Superadmin (nicht Plattform-Admin); Bearbeiten-Dialog für andere nur Lesemodus; Superadmin: Vereinsauswahl beim Archiv-Upload folgt aktiv/gesetzt effective_club_id", + ], + }, { "version": "0.8.57", "date": "2026-05-07", diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 6257be0..8c39e0a 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -240,18 +240,6 @@ export default function MediaLibraryPage() { [isSuperadmin], ) - const modalVisibilityOptions = useMemo(() => { - if (!modalDraft) return archiveVisOptions - const o = [...archiveVisOptions] - if (!o.some((x) => x.value === modalDraft.visibility)) { - o.push({ - value: modalDraft.visibility, - label: visibilityUiLabel(modalDraft.visibility), - }) - } - return o - }, [archiveVisOptions, modalDraft]) - const [lifecycle, setLifecycle] = useState('active') const [q, setQ] = useState('') const [items, setItems] = useState([]) @@ -282,6 +270,25 @@ export default function MediaLibraryPage() { const mediaListFetchSeqRef = useRef(0) const gridTopAnchorRef = useRef(null) + const modalVisibilityOptions = useMemo(() => { + if (!modalDraft) return archiveVisOptions + const o = [...archiveVisOptions] + if (!o.some((x) => x.value === modalDraft.visibility)) { + o.push({ + value: modalDraft.visibility, + label: visibilityUiLabel(modalDraft.visibility), + }) + } + return o + }, [archiveVisOptions, modalDraft]) + + useEffect(() => { + if (!isSuperadmin) return + const cid = user?.effective_club_id ?? user?.active_club_id + if (cid == null || cid === '') return + setUploadClubId(String(cid)) + }, [isSuperadmin, user?.effective_club_id, user?.active_club_id]) + const loadClubs = useCallback(async () => { try { const c = await api.listClubs() @@ -1049,13 +1056,20 @@ export default function MediaLibraryPage() {
-

Medium #{modal.id}

+

+ Medium #{modal.id} + {(modal.visibility || '').toLowerCase() === 'official' && !isSuperadmin + ? ' · Nur Lesen' + : ''} +

- {(viewer?.show_club_meta || viewer?.show_uploader_meta) && ( + {(viewer?.show_club_meta || + viewer?.show_uploader_meta || + (modal.visibility || '').toLowerCase() === 'official') && (
{viewer?.show_club_meta ? (
@@ -1102,6 +1116,18 @@ export default function MediaLibraryPage() { placeholder="z. B. Technik, Wurf" /> + ) : (modal.visibility || '').toLowerCase() === 'official' ? ( + <> +

+ Offizielle Medien sind für alle sichtbar. Bearbeiten, Sichtbarkeit und Löschung nur als Superadmin. +

+ + + +