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({
- 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) ? (