From f354bd9f77c5279cc8ac6b382310036257630d90 Mon Sep 17 00:00:00 2001 From: Lars Date: Fri, 8 May 2026 10:20:41 +0200 Subject: [PATCH] =?UTF-8?q?Governance:=20official=20nur=20Superadmin;=20Pr?= =?UTF-8?q?ivat-Archiv=20Verein=20w=C3=A4hlbar;=20Club-=C3=9Cbung=20Copyri?= =?UTF-8?q?ght;=20gleiche=20Medienordner?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Cursor --- backend/club_tenancy.py | 6 +- backend/media_storage.py | 17 ++-- backend/routers/exercises.py | 98 ++++++++++++++++++- backend/routers/media_assets.py | 8 ++ .../test_club_exercise_media_copyright.py | 62 ++++++++++++ backend/tests/test_library_storage_key.py | 4 +- backend/tests/test_media_assets_archive.py | 2 +- backend/version.py | 16 ++- .../ExerciseProgressionGraphPanel.jsx | 12 ++- frontend/src/pages/ExerciseFormPage.jsx | 19 +++- frontend/src/pages/ExercisesListPage.jsx | 5 +- frontend/src/pages/MediaLibraryPage.jsx | 48 ++++++--- 12 files changed, 259 insertions(+), 38 deletions(-) create mode 100644 backend/tests/test_club_exercise_media_copyright.py diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 10a6135..d665dd4 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -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: diff --git a/backend/media_storage.py b/backend/media_storage.py index 87d84c3..304b738 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -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: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 7e2a620..22865e5 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index bd5e504..fae721d 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -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 diff --git a/backend/tests/test_club_exercise_media_copyright.py b/backend/tests/test_club_exercise_media_copyright.py new file mode 100644 index 0000000..f322849 --- /dev/null +++ b/backend/tests/test_club_exercise_media_copyright.py @@ -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 diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py index 57004d9..693f752 100644 --- a/backend/tests/test_library_storage_key.py +++ b/backend/tests/test_library_storage_key.py @@ -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" ) diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 36c9d9f..03398fd 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -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 diff --git a/backend/version.py b/backend/version.py index b9c51d4..95ead31 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 5314bc2..f9ee3fd 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -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) => ( @@ -599,7 +607,7 @@ export default function ExerciseProgressionGraphPanel({
diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 2be9270..eafad09 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -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 diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 3ff9883..6257be0 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -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() {

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

@@ -647,13 +671,13 @@ export default function MediaLibraryPage() { }} aria-label="Sichtbarkeit für neuen Upload" > - {VIS_OPTIONS.map((o) => ( + {archiveVisOptions.map((o) => ( ))} - {uploadVis === 'club' ? ( + {uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? ( setBulkVis(e.target.value)}> - {VIS_OPTIONS.map((o) => ( + {archiveVisOptions.map((o) => ( ))} - {bulkVis === 'club' ? ( + {bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin) ? ( - {modalDraft.visibility === 'club' ? ( + {modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin) ? ( <>