feat: enhance media library and lifecycle management
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
All checks were successful
Deploy Development / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 25s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s
- Updated media library to include lifecycle filtering options (active, trash_soft, trash_hidden) and copyright management capabilities. - Implemented new API endpoints for listing media assets with lifecycle states and patching copyright notices. - Enhanced frontend components to support navigation to the media library and integration of media management features in the ExerciseFormPage. - Incremented version to 0.8.48, reflecting the latest improvements in media handling and governance.
This commit is contained in:
parent
95f5b0b2d7
commit
0a1816e38b
|
|
@ -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** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — 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
|
||||||
|
|
|
||||||
|
|
@ -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). |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -19,8 +19,9 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
||||||
| 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 |
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
367
frontend/src/pages/MediaLibraryPage.jsx
Normal file
367
frontend/src/pages/MediaLibraryPage.jsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user