Medienmanager und Sicherheitsupdate #21

Merged
Lars merged 15 commits from develop into main 2026-05-07 16:00:19 +02:00
11 changed files with 571 additions and 21 deletions
Showing only changes of commit 0a1816e38b - Show all commits

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Projekt-Status # Shinkan Jinkendo - Projekt-Status
**Stand:** 2026-05-07 **Stand:** 2026-05-07
**Version (Code):** 0.8.43 (`backend/version.py`, APP_VERSION) **Version (Code):** 0.8.48 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`) **DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
**Branch:** develop **Branch:** develop
@ -9,7 +9,7 @@
## Executive Summary ## Executive Summary
**Aktueller Meilenstein (Medien):** **Archiv & Wiederverwendung** auf Basis von **Migration 045** (`media_assets`, `exercise_media.media_asset_id`, Superadmin-Speicherpfad). **Papierkorb §5** (Lifecycle-API, Retention-Job), **Archiv-Liste** (`GET /api/media-assets`), **Direkt-Download** für Thumbnails (`GET /api/media-assets/{id}/file`), **Verknüpfen ohne Upload** (`POST /api/exercises/{id}/media/from-asset`), Übungsformular mit Archiv-Dialog und Vorschau sind umgesetzt. **Aktueller Meilenstein (Medien):** **Medienbibliothek `/media`** ergänzt den Archiv-Picker: **Lifecycle-Filter** (aktiv / Papierkorb / ausgeblendet), **Copyright bearbeiten** (`PATCH`), **Vorschau** inkl. Papierkorb bei Verwaltungsrecht; API-Liste `copyright_notice`; zuvor **§4.2 Promotion official** (Übung + Medien).
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**. **Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
@ -17,8 +17,8 @@
**Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07): **Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
1. **Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2) — höchste fachliche Lücke zur Governance. 1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
2. **Eigenständige Medienmanager-Seite** (Admin/Verein): Metadaten/Copyright bearbeiten, Filtern nach Lifecycle, Bulk — ergänzt den in der Übung eingebetteten Archiv-Picker. 2. ~~**Eigenständige Medienmanager-Seite**~~**Basis umgesetzt** (`/media`): Filter, Copyright, Lifecycle-Aktionen; Ausbau: Sichtbarkeit bearbeiten, Bulk, Quotas.
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren. 3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade. 4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow. 5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
@ -104,8 +104,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### 🔲 In Arbeit / Backlog ### 🔲 In Arbeit / Backlog
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.) - [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
- [ ] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2) - [x] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2) — 0.8.47
- [ ] **Medien:** Eigenständige Medienmanager-UI (Admin/Verein), S3 — Roadmap Executive Summary - [x] **Medien:** Medienbibliothek `/media` (Lifecycle-Filter, Copyright PATCH, Vorschau); Ausbau Manager/Bulk/S3 — Roadmap
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife - [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
- [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen - [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen
- [ ] Responsive Design / Dark Mode / PWA - [ ] Responsive Design / Dark Mode / PWA

View File

@ -188,7 +188,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|-------|----------| |-------|----------|
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. | | 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. |
| 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. | | 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. |
| 2026-05-07 | **Promotion Übung → `official` (Umsetzung):** PUT `/api/exercises/{id}` und `PATCH /api/exercises/bulk-metadata` prüfen angehängte `media_assets` (aktiv, Sichtbarkeit, Copyright ≥3 Zeichen); optional `promote_attached_media_for_official` + `default_official_media_copyright`; Antwort-Codes `OFFICIAL_MEDIA_LIFECYCLE`, `OFFICIAL_MEDIA_CONFIRM_REQUIRED`. | | 2026-05-07 | **Medienmanager (Basis):** `GET /api/media-assets?lifecycle=…`, `copyright_notice` in Response; `PATCH` Copyright; `GET …/file` für Papierkorb-Zeilen bei Verwaltungsrecht; UI-Route `/media` (Medienbibliothek). |
--- ---

View File

@ -19,8 +19,9 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` | | admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users | | platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
| media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | `trash_soft` / `trash_hidden` / `recover` / `purge` / **`reactivate`** (Papierkorb → aktiv); Rechte `assert_can_manage_media_asset_lifecycle` | | media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | `trash_soft` / `trash_hidden` / `recover` / `purge` / **`reactivate`** (Papierkorb → aktiv); Rechte `assert_can_manage_media_asset_lifecycle` |
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | Archiv-Liste: nur `lifecycle_state=active`; Sichtbarkeit wie Bibliothek (official / eigenes private / Vereinsmitglied) | | media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | Direkt-Download für Archiv-Thumbs; `?ssetoken`; nur wenn Asset sichtbar | | media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Metadaten (Copyright); `assert_can_manage_media_asset_lifecycle` |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung |
| exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv | | exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv |
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | | auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT |
| catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen | | catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen |

View File

@ -5,7 +5,7 @@ from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel from pydantic import BaseModel, Field
from club_tenancy import is_platform_admin, library_content_visible_to_profile from club_tenancy import is_platform_admin, library_content_visible_to_profile
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
@ -20,6 +20,26 @@ class MediaLifecycleBody(BaseModel):
action: Literal["trash_soft", "trash_hidden", "recover", "purge", "reactivate"] action: Literal["trash_soft", "trash_hidden", "recover", "purge", "reactivate"]
class MediaAssetPatch(BaseModel):
copyright_notice: Optional[str] = Field(None, max_length=8000)
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
if lc == "active":
return "ma.lifecycle_state = 'active'"
if lc == "trash_soft":
return "ma.lifecycle_state = 'trash_soft'"
if lc == "trash_hidden":
return "ma.lifecycle_state = 'trash_hidden'"
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')"
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
@ -47,12 +67,18 @@ def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict)
def list_media_assets( def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
q: Optional[str] = Query(None, max_length=120), q: Optional[str] = Query(None, max_length=120),
lifecycle: str = Query(
"active",
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
),
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
""" """
Durchsuchbares Medien-Archiv: nur aktive Assets, Sichtbarkeit wie Übungsbibliothek. Durchsuchbares Medien-Archiv; Sichtbarkeit wie Übungsbibliothek.
Standard lifecycle=active (Archiv-Picker); Manager-UI kann Papierkorb-Ansicht wählen.
""" """
lc_where = _lifecycle_where_sql(lifecycle)
role = tenant.global_role or "" role = tenant.global_role or ""
is_adm = is_platform_admin(role) is_adm = is_platform_admin(role)
profile_id = tenant.profile_id profile_id = tenant.profile_id
@ -69,9 +95,10 @@ def list_media_assets(
cur = get_cursor(conn) cur = get_cursor(conn)
cur.execute( cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id, f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256 ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
ma.copyright_notice
FROM media_assets ma FROM media_assets ma
WHERE ma.lifecycle_state = 'active' WHERE {lc_where}
AND ( AND (
%s %s
OR lower(trim(ma.visibility)) = 'official' OR lower(trim(ma.visibility)) = 'official'
@ -90,12 +117,12 @@ def list_media_assets(
) )
) )
{search_sql} {search_sql}
ORDER BY ma.created_at DESC ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
LIMIT %s OFFSET %s""", LIMIT %s OFFSET %s""",
params, params,
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
return {"items": rows, "limit": limit, "offset": offset} return {"items": rows, "limit": limit, "offset": offset, "lifecycle": lifecycle.strip().lower()}
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"]) @router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
@ -113,9 +140,14 @@ def download_media_asset_file(
if not asset: if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden") raise HTTPException(status_code=404, detail="Medium nicht gefunden")
lc = (asset.get("lifecycle_state") or "").strip().lower() lc = (asset.get("lifecycle_state") or "").strip().lower()
if lc != "active": if lc == "active":
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
_assert_can_view_archive_asset(cur, tenant, asset) _assert_can_view_archive_asset(cur, tenant, asset)
elif lc in ("trash_soft", "trash_hidden"):
from media_lifecycle import assert_can_manage_media_asset_lifecycle
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
else:
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
sk = asset.get("storage_key") sk = asset.get("storage_key")
if not sk: if not sk:
@ -177,3 +209,47 @@ def post_media_asset_lifecycle(
return reactivate_media_asset_from_trash(cur, conn, asset_id) return reactivate_media_asset_from_trash(cur, conn, asset_id)
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
@router.patch("/{asset_id}")
def patch_media_asset(
asset_id: int,
body: MediaAssetPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Metadaten (z. B. Copyright) — gleiche Berechtigung wie Lifecycle-Verwaltung."""
from media_lifecycle import assert_can_manage_media_asset_lifecycle
data = body.dict(exclude_unset=True)
if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
with get_db() as conn:
cur = get_cursor(conn)
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row)
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
if "copyright_notice" in data:
cn = data["copyright_notice"]
cur.execute(
"UPDATE media_assets SET copyright_notice = %s, updated_at = NOW() WHERE id = %s",
(cn, asset_id),
)
conn.commit()
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice
FROM media_assets WHERE id = %s""",
(asset_id,),
)
out = r2d(cur.fetchone())
return out

View File

@ -59,6 +59,7 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
"lifecycle_state": "active", "lifecycle_state": "active",
"created_at": None, "created_at": None,
"sha256": "a" * 64, "sha256": "a" * 64,
"copyright_notice": None,
} }
] ]
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)
@ -312,3 +313,86 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["lifecycle_state"] == "active" assert r.json()["lifecycle_state"] == "active"
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=5,
club_ids=frozenset({5}),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchall.return_value = []
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
):
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
assert r.json()["lifecycle"] == "trash_soft"
first_sql = mock_cur.execute.call_args_list[0][0][0]
assert "trash_soft" in first_sql
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?lifecycle=invalid", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cur.fetchone.side_effect = [
{
"id": 9,
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.png",
},
{
"id": 9,
"mime_type": "image/png",
"byte_size": 10,
"original_filename": "x.png",
"visibility": "private",
"club_id": None,
"uploaded_by_profile_id": 10,
"lifecycle_state": "active",
"created_at": None,
"sha256": "b" * 64,
"copyright_notice": "© HoldCo",
},
]
mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("media_lifecycle.assert_can_manage_media_asset_lifecycle", lambda *a, **k: None):
r = client.patch(
"/api/media-assets/9",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"copyright_notice": "© HoldCo"},
)
assert r.status_code == 200
assert r.json()["copyright_notice"] == "© HoldCo"

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.47" APP_VERSION = "0.8.48"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045" DB_SCHEMA_VERSION = "20260507045"
@ -13,7 +13,7 @@ MODULE_VERSIONS = {
"club_join_requests": "1.0.1", # Depends(get_tenant_context) "club_join_requests": "1.0.1", # Depends(get_tenant_context)
"admin_users": "1.0.0", # GET /api/admin/users "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) "platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
"media_assets": "1.2.0", # lifecycle action reactivate (Papierkorb → aktiv) "media_assets": "1.3.0", # Liste lifecycle-Filter; PATCH Metadaten; GET /file für Papierkorb bei Verwaltungsrecht
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.48",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: GET /api/media-assets mit lifecycle (active|trash_soft|trash_hidden|all), copyright_notice in Liste; PATCH /api/media-assets/{id} (Copyright); GET …/file für Papierkorb wenn Lifecycle-Recht; Frontend /media + Admin-Nav + Link Übungsformular",
],
},
{ {
"version": "0.8.47", "version": "0.8.47",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -32,6 +32,7 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
import TrainerContextsPage from './pages/TrainerContextsPage' import TrainerContextsPage from './pages/TrainerContextsPage'
import MediaWikiImportPage from './pages/MediaWikiImportPage' import MediaWikiImportPage from './pages/MediaWikiImportPage'
import AdminUsersPage from './pages/AdminUsersPage' import AdminUsersPage from './pages/AdminUsersPage'
import MediaLibraryPage from './pages/MediaLibraryPage'
import ActiveClubSwitcher from './components/ActiveClubSwitcher' import ActiveClubSwitcher from './components/ActiveClubSwitcher'
import './app.css' import './app.css'
@ -158,6 +159,7 @@ function AppRoutes() {
<Route path="profile" element={<Navigate to="/settings" replace />} /> <Route path="profile" element={<Navigate to="/settings" replace />} />
<Route path="settings" element={<AccountSettingsPage />} /> <Route path="settings" element={<AccountSettingsPage />} />
<Route path="settings/system" element={<SettingsSystemInfoPage />} /> <Route path="settings/system" element={<SettingsSystemInfoPage />} />
<Route path="media" element={<MediaLibraryPage />} />
<Route path="exercises"> <Route path="exercises">
<Route index element={<ExercisesListPage />} /> <Route index element={<ExercisesListPage />} />
<Route path="new" element={<ExerciseFormPage />} /> <Route path="new" element={<ExerciseFormPage />} />

View File

@ -1,5 +1,5 @@
import { NavLink } from 'react-router-dom' import { NavLink } from 'react-router-dom'
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react' import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
/** /**
* Admin-Seiten-Navigation (horizontal) * Admin-Seiten-Navigation (horizontal)
@ -11,6 +11,7 @@ export default function AdminPageNav() {
{ to: '/admin/users', label: 'Nutzer', icon: Users }, { to: '/admin/users', label: 'Nutzer', icon: Users },
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 }, { to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree }, { to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
{ to: '/media', label: 'Medien', icon: Images },
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download } { to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
] ]

View File

@ -1,5 +1,5 @@
import React, { useEffect, useState, useRef, useMemo } from 'react' import React, { useEffect, useState, useRef, useMemo } from 'react'
import { useNavigate, useParams } from 'react-router-dom' import { useNavigate, useParams, Link } from 'react-router-dom'
import api, { buildExerciseApiPayload } from '../utils/api' import api, { buildExerciseApiPayload } from '../utils/api'
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
import RichTextEditor from '../components/RichTextEditor' import RichTextEditor from '../components/RichTextEditor'
@ -1451,6 +1451,9 @@ function ExerciseFormPage() {
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}> <button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
Aus Archiv verknüpfen Aus Archiv verknüpfen
</button> </button>
<Link to="/media" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
Medienbibliothek
</Link>
</div> </div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}> <div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div> <div>

View File

@ -0,0 +1,367 @@
import { useEffect, useState } from 'react'
import { Link } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
import AdminPageNav from '../components/AdminPageNav'
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
const LC_OPTIONS = [
{ value: 'active', label: 'Aktiv' },
{ value: 'trash_soft', label: 'Papierkorb (Stufe 1)' },
{ value: 'trash_hidden', label: 'Ausgeblendet (Stufe 2)' },
{ value: 'all', label: 'Alle (nicht purgiert)' },
]
function lcLabel(code) {
const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState('')
/** @type {Record<number, string>} */
const [copyrightDrafts, setCopyrightDrafts] = useState({})
const [busyId, setBusyId] = useState(null)
useEffect(() => {
let cancelled = false
const t = setTimeout(async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
if (cancelled) return
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
if (!cancelled) setError(e.message || String(e))
} finally {
if (!cancelled) setLoading(false)
}
}, 320)
return () => {
cancelled = true
clearTimeout(t)
}
}, [lifecycle, q])
const refresh = async () => {
setLoading(true)
setError('')
try {
const res = await api.listMediaAssets({
lifecycle,
q: q.trim(),
limit: 100,
offset: 0,
})
setItems(res.items || [])
const nx = {}
for (const it of res.items || []) {
nx[it.id] = it.copyright_notice != null ? String(it.copyright_notice) : ''
}
setCopyrightDrafts(nx)
} catch (e) {
setError(e.message || String(e))
} finally {
setLoading(false)
}
}
const saveCopyright = async (id) => {
const text = copyrightDrafts[id]
if (text === undefined) return
setBusyId(id)
try {
await api.patchMediaAsset(id, { copyright_notice: text })
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
const runLifecycle = async (id, action, confirmMsg) => {
if (confirmMsg && !window.confirm(confirmMsg)) return
setBusyId(id)
try {
await api.postMediaAssetLifecycle(id, action)
await refresh()
} catch (e) {
alert(e.message || String(e))
} finally {
setBusyId(null)
}
}
return (
<div className="app-page media-library-page">
{isPlatformAdmin ? <AdminPageNav /> : null}
<div
style={{
display: 'flex',
flexWrap: 'wrap',
alignItems: 'baseline',
gap: '12px',
marginBottom: '12px',
}}
>
<h1 className="page-title" style={{ margin: 0 }}>
Medienbibliothek
</h1>
<Link to="/exercises" style={{ fontSize: '14px' }}>
Zu den Übungen
</Link>
{isPlatformAdmin ? (
<Link to="/admin/hierarchy" style={{ fontSize: '14px' }}>
Administration
</Link>
) : null}
</div>
<p style={{ color: 'var(--text2)', fontSize: '14px', maxWidth: '52rem', marginTop: 0 }}>
Sichtbare Medien gemäß deinen Rechten (privat, Verein, offiziell). Papierkorb- und
Metadaten-Aktionen wie in der Übungsbearbeitung hier zentral mit Filter.
</p>
<div
className="form-row"
style={{ flexWrap: 'wrap', gap: '10px', alignItems: 'flex-end', marginBottom: '16px' }}
>
<div>
<label className="form-label" htmlFor="media-lib-q">
Suche
</label>
<input
id="media-lib-q"
type="search"
className="form-input"
placeholder="Dateiname …"
value={q}
onChange={(e) => setQ(e.target.value)}
style={{ minWidth: '220px' }}
/>
</div>
<div>
<label className="form-label" htmlFor="media-lib-lc">
Lebenszyklus
</label>
<select
id="media-lib-lc"
className="form-input"
value={lifecycle}
onChange={(e) => setLifecycle(e.target.value)}
>
{LC_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
</div>
<button type="button" className="btn btn-secondary" onClick={refresh} disabled={loading}>
Aktualisieren
</button>
</div>
{error ? (
<p style={{ color: 'var(--danger, crimson)' }} role="alert">
{error}
</p>
) : null}
{loading && items.length === 0 ? <div className="spinner" /> : null}
{!loading && !error && items.length === 0 ? (
<p style={{ color: 'var(--text2)' }}>Keine Einträge.</p>
) : null}
<div style={{ overflowX: 'auto' }}>
<table className="table" style={{ width: '100%', fontSize: '14px' }}>
<thead>
<tr>
<th style={{ width: 56 }} aria-hidden />
<th>Datei</th>
<th>Sichtbarkeit</th>
<th>Status</th>
<th style={{ minWidth: '220px' }}>Copyright</th>
<th style={{ minWidth: '200px' }}>Aktionen</th>
</tr>
</thead>
<tbody>
{items.map((it) => {
const mime = (it.mime_type || '').toLowerCase()
const isImg = mime.startsWith('image/')
const busy = busyId === it.id
const lc = (it.lifecycle_state || '').toLowerCase()
return (
<tr key={it.id}>
<td>
{isImg ? (
<img
src={resolveMediaAssetFileUrl(it.id)}
alt=""
width={48}
height={48}
loading="lazy"
style={{
objectFit: 'cover',
borderRadius: 6,
background: 'var(--surface2, #eee)',
}}
onError={(e) => {
e.target.style.visibility = 'hidden'
}}
/>
) : (
<div
style={{
width: 48,
height: 48,
borderRadius: 6,
background: 'var(--surface2, #eee)',
fontSize: 10,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: 'var(--text2)',
}}
>
{mime.includes('video') ? '▶' : '◆'}
</div>
)}
</td>
<td>
<div style={{ fontWeight: 600 }}>{it.original_filename || `Asset #${it.id}`}</div>
<div style={{ fontSize: '12px', color: 'var(--text2)' }}>
ID {it.id}
{it.byte_size != null ? ` · ${Math.round(it.byte_size / 1024)} KB` : ''}
</div>
</td>
<td>{it.visibility}</td>
<td>{lcLabel(lc)}</td>
<td>
<textarea
className="form-input"
rows={2}
style={{ width: '100%', resize: 'vertical', minHeight: '48px' }}
value={copyrightDrafts[it.id] ?? ''}
onChange={(e) =>
setCopyrightDrafts((prev) => ({ ...prev, [it.id]: e.target.value }))
}
disabled={busy}
/>
<button
type="button"
className="btn btn-secondary"
style={{ marginTop: 6 }}
disabled={busy}
onClick={() => saveCopyright(it.id)}
>
Copyright speichern
</button>
</td>
<td>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
{lc === 'active' ? (
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_soft',
'Medium in den Papierkorb (Stufe 1) legen?',
)
}
>
Papierkorb
</button>
) : null}
{lc === 'trash_soft' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'trash_hidden',
'Medium ausblenden (Stufe 2)? In öffentlichen Ansichten unsichtbar.',
)
}
>
Ausblenden
</button>
</>
) : null}
{lc === 'trash_hidden' ? (
<>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'recover', null)}
>
Zurück Stufe 1
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() => runLifecycle(it.id, 'reactivate', null)}
>
Wieder aktiv
</button>
<button
type="button"
className="btn btn-secondary"
disabled={busy}
onClick={() =>
runLifecycle(
it.id,
'purge',
'Endgültig löschen (Datei und DB-Eintrag)?',
)
}
>
Endgültig löschen
</button>
</>
) : null}
</div>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
</div>
)
}

View File

@ -534,10 +534,18 @@ export async function listMediaAssets(params = {}) {
if (params.q) sp.set('q', params.q) if (params.q) sp.set('q', params.q)
if (params.limit != null) sp.set('limit', String(params.limit)) if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset)) if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
const qs = sp.toString() const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`) return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
} }
export async function patchMediaAsset(assetId, data) {
return request(`/api/media-assets/${assetId}`, {
method: 'PATCH',
body: JSON.stringify(data),
})
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */ /** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function attachExerciseMediaFromAsset(exerciseId, body) { export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, { return request(`/api/exercises/${exerciseId}/media/from-asset`, {
@ -1284,6 +1292,7 @@ export const api = {
reorderExerciseMedia, reorderExerciseMedia,
postMediaAssetLifecycle, postMediaAssetLifecycle,
listMediaAssets, listMediaAssets,
patchMediaAsset,
attachExerciseMediaFromAsset, attachExerciseMediaFromAsset,
listExerciseProgressionGraphs, listExerciseProgressionGraphs,
getExerciseProgressionGraph, getExerciseProgressionGraph,