Governance: official nur Superadmin; Privat-Archiv Verein wählbar; Club-Übung Copyright; gleiche Medienordner
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 47s
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 33s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 47s
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
parent
01636b5baf
commit
f354bd9f77
|
|
@ -157,13 +157,13 @@ def assert_valid_governance_visibility(
|
|||
visibility: str,
|
||||
club_id: Optional[int],
|
||||
) -> None:
|
||||
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin."""
|
||||
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Superadmin."""
|
||||
if visibility not in _GOVERNANCE_VISIBILITY:
|
||||
raise HTTPException(status_code=400, detail="Ungültige visibility")
|
||||
if visibility == "official" and not is_platform_admin(role):
|
||||
if visibility == "official" and not is_superadmin(role):
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
|
||||
detail="Nur Superadmins dürfen offizielle Inhalte setzen",
|
||||
)
|
||||
if visibility == "club":
|
||||
if club_id is None:
|
||||
|
|
|
|||
|
|
@ -150,13 +150,15 @@ def library_storage_key(
|
|||
|
||||
- official → library/official/{kind}/{sha256}{ext}
|
||||
- club (vereinsgeteilt) → library/{vereins-segment}/{kind}/{sha256}{ext}
|
||||
- private (nur Hochlader) → library/{vereins-segment}/u{profile}/{kind}/{sha256}{ext}
|
||||
- private → dieselbe Ordnerlogik wie Verein: library/{vereins-segment}/{kind}/{sha}.u{profile}{ext}
|
||||
|
||||
Dateiname bei private: „{sha}.u{profile_id}{ext}“ (nicht Unterordner u{…}), damit Ordnerstruktur wie bei „Verein“.
|
||||
|
||||
Vereins-Segment: aus club_name abgeleitet + „-c{club_id}“ — siehe library_club_path_segment.
|
||||
|
||||
kind ∈ {image, video, pdf, other} — siehe library_media_kind_dir.
|
||||
|
||||
Kein Ordnername „private“ auf der Platte: Zuordnung erfolgt über Uploader unter dem Verein.
|
||||
Kein Ordnername „private“ auf der Platte. Private Dateien unterscheiden sich nur im Dateinamen (.u{Profil} vor Endung).
|
||||
"""
|
||||
vis = (visibility or "private").strip().lower()
|
||||
if vis not in ("private", "club", "official"):
|
||||
|
|
@ -176,9 +178,9 @@ def library_storage_key(
|
|||
|
||||
kind = library_media_kind_dir(mime_type, e)
|
||||
e = e[:16]
|
||||
blob = f"{kind}/{sha}{e}"
|
||||
club_blob = f"{kind}/{sha}{e}"
|
||||
if vis == "official":
|
||||
return f"library/official/{blob}"
|
||||
return f"library/official/{club_blob}"
|
||||
if club_id is None:
|
||||
raise ValueError("Verein (club_id) ist für diese Sichtbarkeit auf der Platte erforderlich")
|
||||
cid = int(club_id)
|
||||
|
|
@ -186,13 +188,14 @@ def library_storage_key(
|
|||
raise ValueError("club_id muss eine positive Ganzzahl sein")
|
||||
club_seg = library_club_path_segment(cid, club_name)
|
||||
if vis == "club":
|
||||
return f"library/{club_seg}/{blob}"
|
||||
return f"library/{club_seg}/{club_blob}"
|
||||
if uploader_profile_id is None:
|
||||
raise ValueError("uploader_profile_id ist für private Medien erforderlich (Ordner u{…} unter dem Verein)")
|
||||
raise ValueError("uploader_profile_id ist für private Archiv-Medien erforderlich")
|
||||
up = int(uploader_profile_id)
|
||||
if up < 1:
|
||||
raise ValueError("uploader_profile_id muss positiv sein")
|
||||
return f"library/{club_seg}/u{up}/{blob}"
|
||||
priv_name = f"{sha}.u{up}{e}"
|
||||
return f"library/{club_seg}/{kind}/{priv_name}"
|
||||
|
||||
|
||||
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:
|
||||
|
|
|
|||
|
|
@ -642,6 +642,72 @@ def apply_official_exercise_media_rules(
|
|||
)
|
||||
|
||||
|
||||
def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibility: str) -> None:
|
||||
"""
|
||||
Vereins-sichtbare Übung: angehängte Archiv-Dateien müssen aktiv sein und einen Copyright-Vermerk haben
|
||||
(wie bei offiziellen Übungen, ohne Sichtbarkeits-Promotion der Assets).
|
||||
"""
|
||||
nv = (next_visibility or "private").strip().lower()
|
||||
if nv != "club":
|
||||
return
|
||||
|
||||
rows = _fetch_exercise_linked_file_assets(cur, exercise_id)
|
||||
if not rows:
|
||||
return
|
||||
|
||||
blocking_lc: List[Dict[str, Any]] = []
|
||||
missing_cr: List[Dict[str, Any]] = []
|
||||
|
||||
for r in rows:
|
||||
aid = int(r["id"])
|
||||
lc = (r.get("lifecycle_state") or "").strip().lower()
|
||||
cr = _normalize_media_copyright_notice(r.get("copyright_notice"))
|
||||
|
||||
if lc != "active":
|
||||
blocking_lc.append(
|
||||
{
|
||||
"media_asset_id": aid,
|
||||
"lifecycle_state": lc,
|
||||
"visibility": (r.get("visibility") or "").strip().lower(),
|
||||
"original_filename": r.get("original_filename"),
|
||||
}
|
||||
)
|
||||
continue
|
||||
if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN:
|
||||
missing_cr.append(
|
||||
{
|
||||
"media_asset_id": aid,
|
||||
"original_filename": r.get("original_filename"),
|
||||
}
|
||||
)
|
||||
|
||||
if blocking_lc:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "CLUB_MEDIA_LIFECYCLE",
|
||||
"message": (
|
||||
"Nicht aktive Archiv-Medien dürfen nicht an einer vereinsöffentlichen Übung hängen "
|
||||
"(Papierkorb/Recovery zuerst)."
|
||||
),
|
||||
"media_assets": blocking_lc,
|
||||
},
|
||||
)
|
||||
|
||||
if missing_cr:
|
||||
raise HTTPException(
|
||||
status_code=422,
|
||||
detail={
|
||||
"code": "CLUB_MEDIA_COPYRIGHT_REQUIRED",
|
||||
"message": (
|
||||
f"Für vereinsöffentliche Übungen ist ein Copyright-Vermerk pro Datei erforderlich "
|
||||
f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen)."
|
||||
),
|
||||
"media_assets": missing_cr,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]:
|
||||
if not file_path_db or file_path_db.startswith("http"):
|
||||
return None
|
||||
|
|
@ -1247,6 +1313,23 @@ def bulk_patch_exercises_metadata(
|
|||
failed.append(entry)
|
||||
continue
|
||||
|
||||
if (next_vis or "").strip().lower() == "club":
|
||||
try:
|
||||
apply_club_exercise_media_copyright_rules(cur, ex_id, next_vis)
|
||||
except HTTPException as he:
|
||||
d = he.detail
|
||||
entry: Dict[str, Any] = {"id": ex_id}
|
||||
if isinstance(d, dict):
|
||||
entry["detail"] = str(d.get("message") or d.get("code") or "Vereins-Medien-Validierung fehlgeschlagen")
|
||||
if "code" in d:
|
||||
entry["code"] = d["code"]
|
||||
if "media_assets" in d:
|
||||
entry["media_assets"] = d["media_assets"]
|
||||
else:
|
||||
entry["detail"] = _fail_msg(he)
|
||||
failed.append(entry)
|
||||
continue
|
||||
|
||||
sets: List[str] = []
|
||||
vals: List[Any] = []
|
||||
if patch_visibility:
|
||||
|
|
@ -1757,13 +1840,13 @@ def create_exercise(
|
|||
)
|
||||
row = cur.fetchone()
|
||||
exercise_id = row['id'] if isinstance(row, dict) else row[0]
|
||||
|
||||
data = body.dict()
|
||||
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
||||
if (body.visibility or "").strip().lower() == "club":
|
||||
apply_club_exercise_media_copyright_rules(cur, exercise_id, "club")
|
||||
conn.commit()
|
||||
|
||||
# M:N Relations zuweisen
|
||||
data = body.dict()
|
||||
assign_exercise_relations(cur, conn, exercise_id, data)
|
||||
|
||||
# Vollständiges Objekt zurückgeben
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
|
||||
return exercise
|
||||
|
|
@ -1866,6 +1949,11 @@ def update_exercise(
|
|||
cur.execute(query, params)
|
||||
|
||||
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
|
||||
try:
|
||||
apply_club_exercise_media_copyright_rules(cur, exercise_id, next_vis)
|
||||
except HTTPException:
|
||||
conn.rollback()
|
||||
raise
|
||||
conn.commit()
|
||||
|
||||
exercise = enrich_exercise_detail(exercise_id, cur)
|
||||
|
|
|
|||
|
|
@ -550,6 +550,14 @@ def _ingest_library_media_file(
|
|||
elif vis == "private":
|
||||
if club_id_form is not None:
|
||||
next_cid = int(club_id_form)
|
||||
elif is_platform_admin(role):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=(
|
||||
"Private Archiv-Uploads als Plattform-Admin: bitte den Zielverein wählen und "
|
||||
"club_id im Formular setzen (nicht vom allgemeinen Kontext ableiten)."
|
||||
),
|
||||
)
|
||||
else:
|
||||
cid = tenant.effective_club_id
|
||||
next_cid = int(cid) if cid is not None else None
|
||||
|
|
|
|||
62
backend/tests/test_club_exercise_media_copyright.py
Normal file
62
backend/tests/test_club_exercise_media_copyright.py
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
"""Vereins-Übung: Copyright-Pflicht für angehängte Archiv-Dateien."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
import routers.exercises as exercises_mod
|
||||
from routers.exercises import apply_club_exercise_media_copyright_rules
|
||||
|
||||
|
||||
def test_apply_club_exercise_media_copyright_skips_non_club() -> None:
|
||||
apply_club_exercise_media_copyright_rules(object(), 1, "private")
|
||||
|
||||
|
||||
def test_apply_club_exercise_media_copyright_missing_copyright() -> None:
|
||||
orig = exercises_mod._fetch_exercise_linked_file_assets
|
||||
|
||||
def mock_fetch(_cur, eid: int):
|
||||
assert eid == 42
|
||||
return [
|
||||
{
|
||||
"id": 10,
|
||||
"visibility": "private",
|
||||
"club_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"copyright_notice": "",
|
||||
"original_filename": "x.jpg",
|
||||
}
|
||||
]
|
||||
|
||||
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
|
||||
try:
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
apply_club_exercise_media_copyright_rules(object(), 42, "club")
|
||||
assert ei.value.status_code == 422
|
||||
d = ei.value.detail
|
||||
assert isinstance(d, dict)
|
||||
assert d.get("code") == "CLUB_MEDIA_COPYRIGHT_REQUIRED"
|
||||
finally:
|
||||
exercises_mod._fetch_exercise_linked_file_assets = orig
|
||||
|
||||
|
||||
def test_apply_club_exercise_media_copyright_ok() -> None:
|
||||
orig = exercises_mod._fetch_exercise_linked_file_assets
|
||||
|
||||
def mock_fetch(_cur, _eid: int):
|
||||
return [
|
||||
{
|
||||
"id": 10,
|
||||
"visibility": "private",
|
||||
"club_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"copyright_notice": "© Verein 2026",
|
||||
"original_filename": "x.jpg",
|
||||
}
|
||||
]
|
||||
|
||||
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
|
||||
try:
|
||||
apply_club_exercise_media_copyright_rules(object(), 42, "club")
|
||||
finally:
|
||||
exercises_mod._fetch_exercise_linked_file_assets = orig
|
||||
|
|
@ -31,7 +31,7 @@ def test_library_storage_key_private_is_uploader_under_club() -> None:
|
|||
library_storage_key(
|
||||
"private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München"
|
||||
)
|
||||
== f"library/ost-dojo-muenchen-c7/u99/image/{_HEX64}.jpg"
|
||||
== f"library/ost-dojo-muenchen-c7/image/{_HEX64}.u99.jpg"
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -103,5 +103,5 @@ def test_library_storage_key_extension() -> None:
|
|||
def test_library_storage_key_private_no_club_name_fallback() -> None:
|
||||
assert (
|
||||
library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3)
|
||||
== f"library/verein-c2/u3/image/{_HEX64}.jpg"
|
||||
== f"library/verein-c2/image/{_HEX64}.u3.jpg"
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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/image/{'a' * 64}.jpg"
|
||||
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
|
||||
_SK_PRIV_C = f"library/verein-c1/u1/video/{'c' * 64}.mp4"
|
||||
_SK_PRIV_C = f"library/verein-c1/video/{'c' * 64}.u1.mp4"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.56"
|
||||
APP_VERSION = "0.8.57"
|
||||
BUILD_DATE = "2026-05-07"
|
||||
DB_SCHEMA_VERSION = "20260508049"
|
||||
|
||||
|
|
@ -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.11.0", # library/{vereins-slug}-c{id}/ statt library/club/c{id}/
|
||||
"media_assets": "1.12.0", # Privat: Plattform-Admin muss club_id; private Ablage wie Verein (.u{pid} im Dateinamen)
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.17.4", # Übungs-Upload: Vereinsnamen für library-Pfad aus clubs
|
||||
"exercises": "2.18.0", # Vereins-Übung: Copyright-Pflicht File-Assets; official nur Superadmin (Governance)
|
||||
"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,16 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.57",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Governance: visibility=official nur noch Superadmin (nicht Plattform-Admin)",
|
||||
"Medienarchiv: private Uploads als Plattform-Admin erfordern explizites club_id; gleiche Ordnerstruktur wie „Verein“, private Kopien mit .u{Profil} vor Dateiendung",
|
||||
"Übung visibility=Verein: angebundene Archiv-Dateien müssen aktiv sein und Copyright (≥3 Zeichen) haben; UI- und API-Fehlercodes CLUB_MEDIA_*",
|
||||
"Frontend: „Offiziell“ nur Superadmin (Bibliothek, Übungsformular, Bulk-Sichtbarkeit, Progressionsgraphen)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.56",
|
||||
"date": "2026-05-07",
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@
|
|||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import ExercisePickerModal from './ExercisePickerModal'
|
||||
|
||||
const VIS_OPTIONS = [
|
||||
|
|
@ -99,6 +100,13 @@ export default function ExerciseProgressionGraphPanel({
|
|||
anchorExerciseId = null,
|
||||
anchorTitle = null,
|
||||
}) {
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const filteredGraphVisOptions = useMemo(
|
||||
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
||||
[isSuperadmin],
|
||||
)
|
||||
|
||||
const [graphs, setGraphs] = useState([])
|
||||
const [selectedGraphId, setSelectedGraphId] = useState(null)
|
||||
const [edges, setEdges] = useState([])
|
||||
|
|
@ -566,7 +574,7 @@ export default function ExerciseProgressionGraphPanel({
|
|||
value={newGraphVisibility}
|
||||
onChange={(e) => setNewGraphVisibility(e.target.value)}
|
||||
>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
{filteredGraphVisOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
|
|
@ -599,7 +607,7 @@ export default function ExerciseProgressionGraphPanel({
|
|||
<div className="form-row">
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
{filteredGraphVisOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/
|
|||
import RichTextEditor from '../components/RichTextEditor'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
|
||||
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
|
||||
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
|
||||
|
|
@ -406,6 +407,8 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
|
|||
function ExerciseFormPage() {
|
||||
const { id: routeId } = useParams()
|
||||
const navigate = useNavigate()
|
||||
const { user } = useAuth()
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
|
||||
const isEdit = exerciseId != null
|
||||
|
||||
|
|
@ -676,6 +679,20 @@ function ExerciseFormPage() {
|
|||
promote_attached_media_for_official: true,
|
||||
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
|
||||
})
|
||||
} else if (
|
||||
firstErr.status === 422 &&
|
||||
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
|
||||
firstErr.payload?.media_assets
|
||||
) {
|
||||
alert(
|
||||
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
|
||||
)
|
||||
throw firstErr
|
||||
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
|
||||
alert(
|
||||
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
|
||||
)
|
||||
throw firstErr
|
||||
} else {
|
||||
throw firstErr
|
||||
}
|
||||
|
|
@ -1243,7 +1260,7 @@ function ExerciseFormPage() {
|
|||
>
|
||||
<option value="private">Privat</option>
|
||||
<option value="club">Verein</option>
|
||||
<option value="official">Offiziell</option>
|
||||
{isSuperadmin ? <option value="official">Offiziell</option> : null}
|
||||
</select>
|
||||
</div>
|
||||
<div className="form-row">
|
||||
|
|
|
|||
|
|
@ -152,6 +152,7 @@ function applyDashboardExerciseListUrl(mergedFromPrefs) {
|
|||
function ExercisesListPage() {
|
||||
const { user, checkAuth } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [mineOnly, setMineOnly] = useState(() => {
|
||||
try {
|
||||
|
|
@ -557,9 +558,9 @@ function ExercisesListPage() {
|
|||
{ id: 'private', label: 'Privat' },
|
||||
{ id: 'club', label: 'Verein' },
|
||||
]
|
||||
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
||||
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
|
||||
return base
|
||||
}, [isPlatformAdmin])
|
||||
}, [isSuperadmin])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useEffect, useState, useCallback, useRef } from 'react'
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid,
|
||||
|
|
@ -235,6 +235,23 @@ export default function MediaLibraryPage() {
|
|||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const archiveVisOptions = useMemo(
|
||||
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
|
||||
[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([])
|
||||
|
|
@ -374,7 +391,7 @@ export default function MediaLibraryPage() {
|
|||
}
|
||||
if (p.change_visibility) {
|
||||
body.visibility = modalDraft.visibility
|
||||
if (modalDraft.visibility === 'club') {
|
||||
if (modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin)) {
|
||||
const cid = Number(modalDraft.club_id)
|
||||
if (!cid) {
|
||||
alert('Bitte einen Verein wählen.')
|
||||
|
|
@ -421,7 +438,7 @@ export default function MediaLibraryPage() {
|
|||
}
|
||||
if (bulkApplyVis) {
|
||||
body.visibility = bulkVis
|
||||
if (bulkVis === 'club') {
|
||||
if (bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin)) {
|
||||
const cid = Number(bulkClubId)
|
||||
if (!cid) {
|
||||
alert('Bitte einen Verein wählen.')
|
||||
|
|
@ -482,12 +499,18 @@ export default function MediaLibraryPage() {
|
|||
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.')
|
||||
return
|
||||
}
|
||||
if (uploadVis === 'private' && isPlatformAdmin && !Number(uploadClubId)) {
|
||||
window.alert('Als Plattform-Admin: Bitte den Zielverein für private Archiv-Uploads wählen (club_id).')
|
||||
return
|
||||
}
|
||||
setUploadBusy(true)
|
||||
setUploadSummary('')
|
||||
try {
|
||||
const res = await api.bulkUploadMediaAssets(list, {
|
||||
visibility: uploadVis,
|
||||
...(uploadVis === 'club' ? { club_id: Number(uploadClubId) } : {}),
|
||||
...((uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin)) && Number(uploadClubId)
|
||||
? { club_id: Number(uploadClubId) }
|
||||
: {}),
|
||||
})
|
||||
setUploadSummary(
|
||||
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`,
|
||||
|
|
@ -520,8 +543,9 @@ export default function MediaLibraryPage() {
|
|||
</div>
|
||||
</div>
|
||||
<p className="media-library__intro">
|
||||
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner,
|
||||
technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen.
|
||||
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads — „Privat“ steuert nur, wer das Asset in der
|
||||
Datenbank sieht; der Ablageordner folgt dem gewählten Verein wie bei „Verein“. Plattform-Admins wählen den
|
||||
Zielverein bei privatem Archiv-Upload aktiv. Suche durchsucht Bezeichner, Speicherpfad, Copyright und Tags.
|
||||
Bearbeiten über das Menü — Bulk in der unteren Leiste.
|
||||
</p>
|
||||
</header>
|
||||
|
|
@ -647,13 +671,13 @@ export default function MediaLibraryPage() {
|
|||
}}
|
||||
aria-label="Sichtbarkeit für neuen Upload"
|
||||
>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
{archiveVisOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
Upload: {o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{uploadVis === 'club' ? (
|
||||
{uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? (
|
||||
<select
|
||||
className="form-input"
|
||||
value={uploadClubId}
|
||||
|
|
@ -969,13 +993,13 @@ export default function MediaLibraryPage() {
|
|||
{bulkApplyVis ? (
|
||||
<>
|
||||
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
{archiveVisOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{bulkVis === 'club' ? (
|
||||
{bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin) ? (
|
||||
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
|
||||
<option value="">— Verein —</option>
|
||||
{clubs.map((c) => (
|
||||
|
|
@ -1090,13 +1114,13 @@ export default function MediaLibraryPage() {
|
|||
value={modalDraft.visibility}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
|
||||
>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
{modalVisibilityOptions.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{modalDraft.visibility === 'club' ? (
|
||||
{modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin) ? (
|
||||
<>
|
||||
<label className="form-label">Verein</label>
|
||||
<select
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user