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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Lars 2026-05-08 10:20:41 +02:00
parent 01636b5baf
commit f354bd9f77
12 changed files with 259 additions and 38 deletions

View File

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

View File

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

View File

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

View File

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

View 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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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