Medien: offizielle Assets nur Superadmin verwaltbar; Lesemodus; Upload-Verein vorausfüllen
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 28s

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-05-08 10:35:28 +02:00
parent f354bd9f77
commit d3055f6f2f
4 changed files with 90 additions and 46 deletions

View File

@ -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")

View File

@ -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

View File

@ -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",

View File

@ -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() {
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="edit-media-title">
<div className="media-library__modal">
<div className="media-library__modal-head">
<h2 id="edit-media-title">Medium #{modal.id}</h2>
<h2 id="edit-media-title">
Medium #{modal.id}
{(modal.visibility || '').toLowerCase() === 'official' && !isSuperadmin
? ' · Nur Lesen'
: ''}
</h2>
<button type="button" className="media-library__icon-btn" onClick={closeModal} aria-label="Schließen">
<X size={22} />
</button>
</div>
<div className="media-library__modal-body">
{(viewer?.show_club_meta || viewer?.show_uploader_meta) && (
{(viewer?.show_club_meta ||
viewer?.show_uploader_meta ||
(modal.visibility || '').toLowerCase() === 'official') && (
<div className="media-library__meta-block">
{viewer?.show_club_meta ? (
<div>
@ -1102,6 +1116,18 @@ export default function MediaLibraryPage() {
placeholder="z. B. Technik, Wurf"
/>
</>
) : (modal.visibility || '').toLowerCase() === 'official' ? (
<>
<p className="media-library__hint">
Offizielle Medien sind für alle sichtbar. Bearbeiten, Sichtbarkeit und Löschung nur als Superadmin.
</p>
<label className="form-label">Bezeichnung</label>
<input className="form-input" readOnly value={modalDraft.display_name} />
<label className="form-label">Copyright</label>
<textarea className="form-input" readOnly rows={3} value={modalDraft.copyright_notice} />
<label className="form-label">Schlagwörter</label>
<input className="form-input" readOnly value={modalDraft.tags_input} />
</>
) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p>
)}
@ -1155,9 +1181,11 @@ export default function MediaLibraryPage() {
</div>
<div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
{modal.permissions?.edit_metadata ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
) : null}
</div>
<div className="media-library__lc-block">