feat: enhance media lifecycle management and inline media integration
All checks were successful
Deploy Development / deploy (push) Successful in 34s
Test Suite / pytest-backend (push) Successful in 23s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 24s

- Implemented media lifecycle management with new API endpoints for handling asset states (trash_soft, trash_hidden, recover, purge), improving media governance.
- Updated frontend components to filter and display media based on lifecycle states, enhancing user experience and visibility.
- Enhanced documentation in MEDIA_ASSETS_AND_ARCHIVE_SPEC.md to include guidelines for inline media references in exercise texts, establishing a clear implementation plan.
- Incremented version to 0.8.42, reflecting the latest changes in media handling and lifecycle management.
This commit is contained in:
Lars 2026-05-07 12:55:50 +02:00
parent ece08ec1a1
commit 8ac723eafe
16 changed files with 553 additions and 20 deletions

View File

@ -20,7 +20,19 @@
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md) **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md)
**Nächste Schritte (Auszug):** **Nächste Schritte — Medien & Archiv** (siehe `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`):
1. Papierkorb-Lebenszyklus (§5) + Hintergrundjob
2. Übungs-Promotions-Dialog inkl. Medien-Freigabe + Copyright-Pflicht bei `official`
3. Medienmanager-/Archiv-UI (Superadmin + Verein); ggf. „Aus Archiv verknüpfen“
4. S3-/ externes Backend hinter Speicher-Abstraktion
5. **Inline-Medien im Fließtext** erst nach stabilem Archiv (Spec §11 — Platzhalter, zentraler Renderer)
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** festgehalten; Umsetzung bewusst verschoben, um Refactor zu vermeiden.
---
**Nächste Schritte (Auszug — Planung/Rahmen):**
1. KalenderUI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. 1. KalenderUI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk.
2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). 2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API).
@ -95,6 +107,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### 🔲 In Arbeit / Backlog ### 🔲 In Arbeit / Backlog
- [ ] **Medien:** Papierkorb (§5), Promotion/Copyright, Archiv-UI, S3 — Roadmap oben im Executive Summary
- [ ] **Medien:** Inline im Fließtext — erst nach Archiv-Stabilität (**`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**)
- [ ] 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
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus - [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
@ -146,7 +160,7 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise | | Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) | | Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) | | Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ Single Source of Truth | | Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ §11 Inline-Plan, Drift-Tab |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei | | Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei |
--- ---

View File

@ -1,7 +1,7 @@
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth) # Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien **Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien
**Stand:** 2026-05-07 **Stand:** 2026-05-07 (§11 Inline-Plan ergänzt)
**ersetzt/ergänzt:** operatives Upload-Format/Größen siehe weiterhin `MEDIA_UPLOAD_SPEC.md` **ersetzt/ergänzt:** operatives Upload-Format/Größen siehe weiterhin `MEDIA_UPLOAD_SPEC.md`
**normative Governance:** Sichtbarkeit & Mandanten wie `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`; bei Abweichung zwischen Dokumenten hat der Zugriffsplan Vorrang, **außer** dieses Dokument präzisiert explizit nur den Medien-Domain. **normative Governance:** Sichtbarkeit & Mandanten wie `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`; bei Abweichung zwischen Dokumenten hat der Zugriffsplan Vorrang, **außer** dieses Dokument präzisiert explizit nur den Medien-Domain.
@ -159,7 +159,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
**Hinweis Beta:** Primär ein Verein, Videos auf separatem physischen System → typischerweise **ein NAS-Mount** als `MEDIA_ROOT`; Superadmin kann bei Bedarf einen **Unterordner** setzen, ohne neues Image zu bauen. **Hinweis Beta:** Primär ein Verein, Videos auf separatem physischen System → typischerweise **ein NAS-Mount** als `MEDIA_ROOT`; Superadmin kann bei Bedarf einen **Unterordner** setzen, ohne neues Image zu bauen.
**Drift:** Jede Änderung an Speicher-Konfiguration oder Asset-Schema → Migration + Eintrag **§10 Changelog** + bei neuen Endpoints **ACCESS_LAYER_ENDPOINT_AUDIT.md**. **Drift:** Jede Änderung an Speicher-Konfiguration oder Asset-Schema → Migration + Eintrag **§10 Changelog** + bei neuen Endpoints **ACCESS_LAYER_ENDPOINT_AUDIT.md**. Änderungen an **Inline-Platzhaltern** (§11) → ebenfalls Changelog + ggf. Frontend-Sanitize-Regeln dokumentieren.
--- ---
@ -177,6 +177,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
| DB-Schema Medien | Neue Migration + **Abschnitt/Changelog in diesem Dokument** (Datum, Kurzbeschreibung) | | DB-Schema Medien | Neue Migration + **Abschnitt/Changelog in diesem Dokument** (Datum, Kurzbeschreibung) |
| Sichtbarkeit / Enums | Zuerst `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, dann dieses Dokument synchron halten | | Sichtbarkeit / Enums | Zuerst `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, dann dieses Dokument synchron halten |
| Neue Endpoints Medien/Archiv | `ACCESS_LAYER_ENDPOINT_AUDIT.md` + `get_tenant_context`/Governance | | Neue Endpoints Medien/Archiv | `ACCESS_LAYER_ENDPOINT_AUDIT.md` + `get_tenant_context`/Governance |
| **Inline-Referenzen** in Übungstexten (§11) | Renderer/Sanitizer-Regeln + dieses Dokument §11; kein zweites Rechtemodell |
| Implementierungs-Meilenstein | `backend/version.py` (`MODULE_VERSIONS` / Schema-Kommentar) und bei größerem Release `PROJECT_STATUS.md` | | Implementierungs-Meilenstein | `backend/version.py` (`MODULE_VERSIONS` / Schema-Kommentar) und bei größerem Release `PROJECT_STATUS.md` |
--- ---
@ -186,11 +187,50 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
| Datum | Änderung | | Datum | Änderung |
|-------|----------| |-------|----------|
| 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 | §7.1 Konfiguration Bootstrap vs. Superadmin (`platform_media_storage`), NAS/Mount-Hinweis, Drift-Regel. Umsetzung Start: DB `media_assets`, FK `exercise_media.media_asset_id`, API Speichereinstellungen Superadmin. | | 2026-05-07 | §7.1 Konfiguration Bootstrap vs. Superadmin (`platform_media_storage`), NAS/Mount-Hinweis, Drift-Regel. Umsetzung Start: DB `media_assets`, FK `exercise_media.media_asset_id`, API Speichereinstellungen Superadmin. |
--- ---
## 11. Referenzen ## 11. Inline-Medien im Fließtext (Planung, Leitplanken)
**Status:** nicht implementiert; verbindlich nur als **Richtschnur**, damit später **kein Big-Bang-Refactor** nötig ist.
### 11.1 Ziel
- Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`.
- **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1).
### 11.2 Platzhalter-Konvention (Vorschlag für spätere Umsetzung)
- Beim **Speichern** im Rich-Text: markierter Verweis, z.B.
`data-shinkan-exercise-media="<numerische exercise_media.id>"` auf einem **neutralen** Element (`span`/`figure`), **oder** eine interne Kurzsyntax (`{{exerciseMedia:123}}`), die der Server beim Speichern in eine **kanonische** HTML-Form überführt.
- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele).
### 11.3 Rendering & Sicherheit
- **Ein zentraler Pfad** „Übungstext für Anzeige aufbereiten“: HTML sanitizen (Allowlist), erlaubte Platzhalter auflösen, **ID gehört zur aktuellen Übung** und Medium ist für den Nutzer **sichtbar** sonst Platzhalter mit neutralem Hinweis oder ausblenden.
- **XSS/CSP:** keine rohen `iframe`/Skripte aus Nutzer-HTML ohne Kontrolle; eingebettete Player nur über kontrollierte Komponenten.
### 11.4 Koexistenz mit Sektions-Medien
- **Liste + Inline** dürfen dasselbe `exercise_media` referenzieren (ein Player an zwei Stellen) Produktentscheidung: später optional „Duplikat vermeiden“-Hinweis in der UI.
- Import/Wiki: vor großen Content-Migrationen **Syntax festlegen**, damit nicht irreversibel „falsches“ HTML importiert wird.
### 11.5 Wann umsetzen (Reihenfolge)
1. **Vorher:** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Basis-Papierkorb (§5) jeweils stabil.
2. **Danach:** Inline implementieren, sobald Trainer-Feedback oder Content-Menge den Bedarf **konkret** bestätigt (typisch nach 12 Beta-Zyklen).
3. **Nicht nötig:** vorher kompletten Block-Editor einführen; **Platzhalter im bestehenden RTE** ist der vorgesehene **schlanke** Einstieg.
### 11.6 Refactor-Vermeidung (jetzt schon)
- Neue Features **nicht** so bauen, dass HTML aus Übungstexten an **vielen** Stellen „roh“ gerendert wird **ein** wiederverwendbarer Renderer vorbereiten (schrittweise einziehen).
- Medien immer über **stabile IDs** anbinden, nicht nur über Datei-URLs im Text.
---
## 12. Referenzen
- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` - `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
- `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend) - `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend)

View File

@ -6,8 +6,8 @@
**Autor:** Claude Code **Autor:** Claude Code
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`) **Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung:** > **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung, später Inline im Fließtext:**
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (gleicher Ordner). > Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (§11 Leitplanken Inline, ohne Umsetzung).
> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor. > Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor.
--- ---

View File

@ -18,6 +18,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id | | training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
| 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 | Papierkorb: `trash_soft` / `trash_hidden` / `recover` / `purge`; Rechte über Uploader, `can_manage_club_org`, Superadmin (`assert_can_manage_media_asset_lifecycle`) |
| 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 |
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | | skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
@ -29,7 +30,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
Letzte Änderung: 2026-05-07 — `platform_media_storage` Admin-API (Speicherpfad Superadmin). Letzte Änderung: 2026-05-07 — `media_assets` Lifecycle-API (Papierkorb); `platform_media_storage` Admin-API (Speicherpfad Superadmin).
--- ---

View File

@ -106,7 +106,7 @@ Kurz (Stand 2026-05-05): App **0.8.10**, DBSchemaVersion **`20260505037`**
- `exercises` - Übungen (Kernobjekt) - `exercises` - Übungen (Kernobjekt)
- `exercise_variants` - Übungsvarianten - `exercise_variants` - Übungsvarianten
- `exercise_skills` - M:N Übung ↔ Fähigkeit - `exercise_skills` - M:N Übung ↔ Fähigkeit
- `exercise_media` - Medien (Bilder, Videos) - `exercise_media` - Medien (Bilder, Videos); Zielbild Archiv & Lifecycle: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. **Inline im Fließtext** (Übungstexte): geplant §11 derselben Spec — Anker `exercise_media.id`, einheitlicher Render-Pfad; noch nicht implementiert.
**Trainingsplanung / Rahmen:** **Trainingsplanung / Rahmen:**
- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**) - `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**)

View File

@ -192,7 +192,7 @@ def read_root():
return out return out
# Register routers # Register routers
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, media_assets, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
app.include_router(auth.router) app.include_router(auth.router)
app.include_router(profiles.router) app.include_router(profiles.router)
@ -203,6 +203,7 @@ app.include_router(club_memberships.router)
app.include_router(club_join_requests.router) app.include_router(club_join_requests.router)
app.include_router(admin_users.router) app.include_router(admin_users.router)
app.include_router(platform_media_storage.router) app.include_router(platform_media_storage.router)
app.include_router(media_assets.router)
app.include_router(skills.router) app.include_router(skills.router)
app.include_router(training_planning.router) app.include_router(training_planning.router)
app.include_router(training_framework_programs.router) app.include_router(training_framework_programs.router)

177
backend/media_lifecycle.py Normal file
View File

@ -0,0 +1,177 @@
"""
Medien-Lebenszyklus (Papierkorb) siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §5.
"""
from __future__ import annotations
import logging
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Optional
from fastapi import HTTPException
from club_tenancy import can_manage_club_org, is_platform_admin
from db import r2d
from media_storage import get_effective_media_root, path_under_media_root
logger = logging.getLogger(__name__)
LC_ACTIVE = "active"
LC_TRASH_SOFT = "trash_soft"
LC_TRASH_HIDDEN = "trash_hidden"
SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30")))
HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90")))
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
"""
Wer Medien in Papierkorb / Recovery / Purge versetzen darf (§5.2 Kurzfassung).
"""
profile_id = tenant.profile_id
role = (tenant.global_role or "").strip().lower()
if is_platform_admin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
uid = asset.get("uploaded_by_profile_id")
if vis == "private":
if uid is not None and int(uid) == int(profile_id):
return
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
if vis == "official":
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
if vis == "club":
cid = asset.get("club_id")
if cid is None:
raise HTTPException(status_code=403, detail="Ungültiges Vereins-Medium")
if can_manage_club_org(cur, profile_id, int(cid), role):
return
raise HTTPException(status_code=403, detail="Nur Vereinsorganisation/Plattform-Admin")
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
storage_key, storage_backend, trash_soft_at, trash_hidden_at, purge_after_at
FROM media_assets WHERE id = %s""",
(asset_id,),
)
row = cur.fetchone()
return r2d(row) if row else None
def purge_asset_filesystem(cur: Any, asset: dict) -> None:
sk = asset.get("storage_key")
if not sk:
return
root = get_effective_media_root(cur)
p = path_under_media_root(root, str(sk))
if p and p.is_file():
try:
p.unlink()
except OSError as e:
logger.warning("Physische Medien-Löschung fehlgeschlagen: %s", e)
def purge_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
"""Löscht Verknüpfungen, Datei und DB-Zeile. Returns True wenn ausgeführt."""
cur.execute(
"""SELECT id, storage_key FROM media_assets
WHERE id = %s AND lifecycle_state = %s""",
(asset_id, LC_TRASH_HIDDEN),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
purge_asset_filesystem(cur, asset)
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
conn.commit()
return True
def transition_to_trash_soft(cur: Any, conn: Any, asset_id: int) -> dict:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_soft_at = NOW(), updated_at = NOW(),
trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s AND lifecycle_state = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id, LC_ACTIVE),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Nur aktive Medien können in den Papierkorb")
conn.commit()
return r2d(row)
def transition_to_trash_hidden(cur: Any, conn: Any, asset_id: int, *, set_purge_after: Optional[datetime] = None) -> dict:
now = datetime.now(timezone.utc)
if set_purge_after is None:
set_purge_after = now + timedelta(days=HIDDEN_TO_PURGE_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = %s
WHERE id = %s AND lifecycle_state IN (%s, %s)
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
(LC_TRASH_HIDDEN, set_purge_after, asset_id, LC_ACTIVE, LC_TRASH_SOFT),
)
row = cur.fetchone()
if not row:
raise HTTPException(
status_code=400,
detail="Nur aktive oder Papierkorb-Stufe-1 Medien können ausgeblendet werden",
)
conn.commit()
return r2d(row)
def transition_recover_from_hidden(cur: Any, conn: Any, asset_id: int) -> dict:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, updated_at = NOW(),
trash_hidden_at = NULL, purge_after_at = NULL
WHERE id = %s AND lifecycle_state = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id, LC_TRASH_HIDDEN),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=400, detail="Nur ausgeblendete Medien können zurückgestuft werden")
conn.commit()
return r2d(row)
def run_retention_pass(cur: Any, conn: Any) -> dict:
"""
Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS trash_hidden;
trash_hidden mit purge_after_at in der Vergangenheit purge.
"""
cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(),
purge_after_at = NOW() + (%s * INTERVAL '1 day')
WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s
RETURNING id""",
(LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft),
)
n_hidden = len(cur.fetchall())
conn.commit()
cur.execute(
"""SELECT id FROM media_assets
WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()""",
(LC_TRASH_HIDDEN,),
)
purge_ids = [r2d(r)["id"] for r in cur.fetchall()]
purged = 0
for aid in purge_ids:
if purge_media_asset(cur, conn, int(aid)):
purged += 1
return {"moved_to_hidden": n_hidden, "purged": purged}

View File

@ -541,7 +541,8 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
cur.execute( cur.execute(
"""SELECT em.id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename, """SELECT em.id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context,
em.media_asset_id, ma.copyright_notice AS asset_copyright_notice em.media_asset_id, ma.copyright_notice AS asset_copyright_notice,
ma.lifecycle_state AS asset_lifecycle_state
FROM exercise_media em FROM exercise_media em
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
WHERE em.exercise_id = %s WHERE em.exercise_id = %s
@ -1967,7 +1968,8 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
cur.execute( cur.execute(
"""SELECT em.id, em.exercise_id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename, """SELECT em.id, em.exercise_id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.created_at, em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.created_at,
em.media_asset_id, ma.storage_key AS asset_storage_key em.media_asset_id, ma.storage_key AS asset_storage_key,
ma.lifecycle_state AS asset_lifecycle_state
FROM exercise_media em FROM exercise_media em
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
WHERE em.id = %s AND em.exercise_id = %s""", WHERE em.id = %s AND em.exercise_id = %s""",
@ -1997,6 +1999,12 @@ def download_exercise_media_file(
if (media.get("embed_url") or "").strip(): if (media.get("embed_url") or "").strip():
raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL") raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL")
lc = (media.get("asset_lifecycle_state") or "active").strip().lower()
if lc == "trash_hidden":
_assert_can_edit_exercise(cur, exercise_id, tenant)
elif lc not in ("active", "trash_soft", ""):
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
fp = media.get("file_path") fp = media.get("file_path")
media_root = get_effective_media_root(cur) media_root = get_effective_media_root(cur)
abs_p = _resolve_local_media_file( abs_p = _resolve_local_media_file(
@ -2116,9 +2124,9 @@ async def upload_exercise_media(
"""SELECT id, storage_key, byte_size FROM media_assets """SELECT id, storage_key, byte_size FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s) AND (club_id IS NOT DISTINCT FROM %s)
AND lifecycle_state = 'active' AND lifecycle_state = %s
LIMIT 1""", LIMIT 1""",
(full_sha, ex_vis, ex_club), (full_sha, ex_vis, ex_club, "active"),
) )
existing_asset = cur.fetchone() existing_asset = cur.fetchone()

View File

@ -0,0 +1,60 @@
"""Lifecycle für media_assets (Papierkorb) — MEDIA_ASSETS_AND_ARCHIVE_SPEC §5."""
from __future__ import annotations
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException
from pydantic import BaseModel
from db import get_db, get_cursor
from tenant_context import TenantContext, get_tenant_context
from media_lifecycle import (
assert_can_manage_media_asset_lifecycle,
fetch_media_asset_row,
purge_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden,
transition_to_trash_soft,
)
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
class MediaLifecycleBody(BaseModel):
action: Literal["trash_soft", "trash_hidden", "recover", "purge"]
@router.post("/{asset_id}/lifecycle")
def post_media_asset_lifecycle(
asset_id: int,
body: MediaLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context),
):
"""Papierkorb-Übergänge (manuell). Rechte gemäß Spec §5.2."""
with get_db() as conn:
cur = get_cursor(conn)
asset = fetch_media_asset_row(cur, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
action = body.action
if action == "trash_soft":
return transition_to_trash_soft(cur, conn, asset_id)
if action == "trash_hidden":
return transition_to_trash_hidden(cur, conn, asset_id)
if action == "recover":
return transition_recover_from_hidden(cur, conn, asset_id)
if action == "purge":
state = (asset.get("lifecycle_state") or "").strip().lower()
if state != "trash_hidden":
raise HTTPException(
status_code=400,
detail="Nur ausgeblendete Medien (Stufe 2) dürfen endgültig gelöscht werden",
)
ok = purge_media_asset(cur, conn, asset_id)
if not ok:
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
return {"ok": True, "purged": asset_id}
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")

View File

@ -0,0 +1,35 @@
#!/usr/bin/env python3
"""
Automatische Medien-Retention (Papierkorb Stufe 12, Purge wenn fällig).
Lauf z. B. täglich per Cron:
cd /path/to/backend && python scripts/media_retention_job.py
Umgebung wie Backend (DB_*), optional:
MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS (Default 30)
MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS (Default 90)
"""
from __future__ import annotations
import os
import sys
# Repo-Root: backend/scripts -> parents[1] == backend
_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))
if _ROOT not in sys.path:
sys.path.insert(0, _ROOT)
from db import get_db, get_cursor # noqa: E402
from media_lifecycle import run_retention_pass # noqa: E402
def main() -> int:
with get_db() as conn:
cur = get_cursor(conn)
summary = run_retention_pass(cur, conn)
print(summary)
return 0
if __name__ == "__main__":
raise SystemExit(main())

View File

@ -0,0 +1,74 @@
"""GET/PUT /api/admin/platform-media-storage — Auth-Matrix (kein Live-DB nötig)."""
from __future__ import annotations
import os
from unittest.mock import MagicMock, patch
import pytest
from fastapi.testclient import TestClient
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
from auth import require_auth
from main import app
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
@pytest.fixture(autouse=True)
def _clear_overrides():
yield
app.dependency_overrides.pop(require_auth, None)
def test_platform_media_storage_get_requires_platform_admin(client: TestClient) -> None:
def _trainer():
return {"profile_id": 1, "role": "trainer"}
app.dependency_overrides[require_auth] = _trainer
r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_platform_media_storage_put_requires_superadmin(client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
r = client.put(
"/api/admin/platform-media-storage",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"local_relative_root": "foo"},
)
assert r.status_code == 403
@patch("routers.platform_media_storage.get_effective_media_root", return_value=__import__("pathlib").Path("/tmp/media"))
def test_platform_media_storage_get_ok_for_admin(mock_root, client: TestClient) -> None:
def _admin():
return {"profile_id": 1, "role": "admin"}
app.dependency_overrides[require_auth] = _admin
mock_cm = MagicMock()
mock_conn = MagicMock()
mock_cm.__enter__.return_value = mock_conn
mock_cm.__exit__.return_value = False
mock_cur = MagicMock()
mock_cur.fetchone.return_value = {
"storage_backend": "local",
"local_relative_root": "nas",
}
with patch("routers.platform_media_storage.get_db", return_value=mock_cm), patch(
"routers.platform_media_storage.get_cursor", return_value=mock_cur
):
r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
body = r.json()
assert body["storage_backend"] == "local"
assert body["local_relative_root"] == "nas"

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.41" APP_VERSION = "0.8.42"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045" DB_SCHEMA_VERSION = "20260507045"
@ -13,10 +13,11 @@ 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.0.0", # POST /api/media-assets/{id}/lifecycle (Papierkorb §5)
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
"exercises": "2.11.0", # media_assets + Dedupe-Upload; effektives MEDIA_ROOT aus platform_media_storage "exercises": "2.12.0", # Detail/Liste: asset_lifecycle_state; Download trash_hidden nur für Bearbeiter; Lesen filtert hidden
"training_units": "0.2.0", "training_units": "0.2.0",
"training_programs": "0.1.0", "training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -28,6 +29,15 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.42",
"date": "2026-05-07",
"changes": [
"Medien-Papierkorb: POST /api/media-assets/{id}/lifecycle (trash_soft, trash_hidden, recover, purge); Retention-Job scripts/media_retention_job.py",
"Übungen: GET-Detail inkl. asset_lifecycle_state; Bearbeitungsrechte Erweiterung (Vereinsplanung); Frontend Übung bearbeiten: Reihenfolge, Papierkorb-Actions; Detail/Katalog: trash_hidden ausgeblendet, Hinweis trash_soft",
"Fix: ExerciseDetailPage zeigt „Hinweise für Trainer“ wieder an",
],
},
{ {
"version": "0.8.41", "version": "0.8.41",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -113,6 +113,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}> <div className="exercise-coach-catalog" style={{ fontSize: '0.93rem', lineHeight: 1.5 }}>
@ -161,14 +165,19 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
<HtmlBlock html={exercise.execution} /> <HtmlBlock html={exercise.execution} />
</section> </section>
)} )}
{(exercise.media || []).length > 0 && ( {visibleMedia.length > 0 && (
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}> <section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}> <h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
Medien Medien
</h3> </h3>
{exercise.media.map((m) => ( {visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '12px' }}> <div key={m.id} style={{ marginBottom: '12px' }}>
<strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong> <strong style={{ fontSize: '0.9rem' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '0.75rem', color: 'var(--danger)', margin: '4px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>} {m.description && <p style={{ color: 'var(--text2)', fontSize: '0.82rem', marginTop: '4px' }}>{m.description}</p>}
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} /> <MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
</div> </div>

View File

@ -151,6 +151,10 @@ function ExerciseDetailPage() {
if (!exercise) return null if (!exercise) return null
const meta = metaParts(exercise) const meta = metaParts(exercise)
const visibleMedia = (exercise.media || []).filter((m) => {
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
return lc !== 'trash_hidden'
})
return ( return (
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}> <div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
@ -211,12 +215,17 @@ function ExerciseDetailPage() {
</section> </section>
)} )}
{(exercise.media || []).length > 0 && ( {visibleMedia.length > 0 && (
<section className="card exercise-detail-section"> <section className="card exercise-detail-section">
<h2>Medien</h2> <h2>Medien</h2>
{exercise.media.map((m) => ( {visibleMedia.map((m) => (
<div key={m.id} style={{ marginBottom: '1.25rem' }}> <div key={m.id} style={{ marginBottom: '1.25rem' }}>
<strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong> <strong style={{ fontSize: '15px' }}>{m.title || m.original_filename || m.media_type}</strong>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ fontSize: '12px', color: 'var(--danger)', margin: '6px 0 0' }}>
Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung.
</p>
)}
{m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>} {m.description && <p style={{ color: 'var(--text2)', fontSize: '0.9rem' }}>{m.description}</p>}
<MediaBlock media={m} exerciseId={exercise.id} /> <MediaBlock media={m} exerciseId={exercise.id} />
</div> </div>

View File

@ -612,6 +612,16 @@ function ExerciseFormPage() {
} }
} }
const runMediaLifecycle = async (assetId, action) => {
if (!assetId) return
try {
await api.postMediaAssetLifecycle(assetId, action)
await refreshMedia()
} catch (e) {
alert(e.message || String(e))
}
}
const moveMediaRow = async (idx, dir) => { const moveMediaRow = async (idx, dir) => {
if (!exerciseId) return if (!exerciseId) return
const j = idx + dir const j = idx + dir
@ -1321,6 +1331,82 @@ function ExerciseFormPage() {
</> </>
)} )}
</div> </div>
{m.media_asset_id ? (
<div style={{ marginTop: '8px', fontSize: '12px' }}>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
Papierkorb (Stufe 1): für neue Zuordnungen gesperrt.
</p>
)}
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
Ausgeblendet (Stufe 2): in der Übungsansicht nicht sichtbar.
</p>
)}
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'active' && (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => {
if (confirm('Medium in den Papierkorb (Stufe 1)?')) {
runMediaLifecycle(m.media_asset_id, 'trash_soft')
}
}}
>
Papierkorb (Stufe 1)
</button>
)}
{['active', 'trash_soft'].includes(String(m.asset_lifecycle_state || 'active').toLowerCase()) && (
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => {
if (confirm('Medium ausblenden (Stufe 2)? Lesende Nutzer sehen es nicht mehr.')) {
runMediaLifecycle(m.media_asset_id, 'trash_hidden')
}
}}
>
Ausblenden (Stufe 2)
</button>
)}
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '4px 8px' }}
onClick={() => runMediaLifecycle(m.media_asset_id, 'recover')}
>
Wiederherstellen (Stufe 1)
</button>
<button
type="button"
className="btn"
style={{
fontSize: '11px',
padding: '4px 8px',
background: 'var(--danger)',
color: '#fff',
border: 'none',
}}
onClick={() => {
if (
confirm('Endgültig löschen? Entfernt Datei und Verknüpfungen dieser Medien-ID.')
) {
runMediaLifecycle(m.media_asset_id, 'purge')
}
}}
>
Endgültig löschen
</button>
</>
)}
</div>
</div>
) : null}
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}> <div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input <input
type="text" type="text"

View File

@ -502,6 +502,14 @@ export async function reorderExerciseMedia(exerciseId, mediaIds) {
}) })
} }
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge */
export async function postMediaAssetLifecycle(assetId, action) {
return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST',
body: JSON.stringify({ action }),
})
}
export async function getExercise(id) { export async function getExercise(id) {
return request(`/api/exercises/${id}`) return request(`/api/exercises/${id}`)
} }
@ -1203,6 +1211,7 @@ export const api = {
updateExerciseMedia, updateExerciseMedia,
deleteExerciseMedia, deleteExerciseMedia,
reorderExerciseMedia, reorderExerciseMedia,
postMediaAssetLifecycle,
listExerciseProgressionGraphs, listExerciseProgressionGraphs,
getExerciseProgressionGraph, getExerciseProgressionGraph,
createExerciseProgressionGraph, createExerciseProgressionGraph,