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
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:
parent
ece08ec1a1
commit
8ac723eafe
|
|
@ -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)
|
||||
|
||||
**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. Kalender‑UI: „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).
|
||||
|
|
@ -95,6 +107,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### 🔲 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
|
||||
- [ ] Responsive Design / Dark Mode / PWA
|
||||
- [ ] 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 |
|
||||
| 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) |
|
||||
| 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 |
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
|
||||
|
||||
**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`
|
||||
**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.
|
||||
|
||||
**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) |
|
||||
| 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 |
|
||||
| **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` |
|
||||
|
||||
---
|
||||
|
|
@ -186,11 +187,50 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|
|||
| 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 | §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. |
|
||||
|
||||
---
|
||||
|
||||
## 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 1–2 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/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend)
|
||||
|
|
|
|||
|
|
@ -6,8 +6,8 @@
|
|||
**Autor:** Claude Code
|
||||
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
|
||||
|
||||
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung:**
|
||||
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (gleicher Ordner).
|
||||
> **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`** (§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.
|
||||
|
||||
---
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| 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` |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
|
@ -29,7 +30,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
|
||||
**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).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -106,7 +106,7 @@ Kurz (Stand 2026-05-05): App **0.8.10**, DB‑Schema‑Version **`20260505037`**
|
|||
- `exercises` - Übungen (Kernobjekt)
|
||||
- `exercise_variants` - Übungsvarianten
|
||||
- `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:**
|
||||
- `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**)
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# 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(profiles.router)
|
||||
|
|
@ -203,6 +203,7 @@ app.include_router(club_memberships.router)
|
|||
app.include_router(club_join_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(media_assets.router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
|
|
|
|||
177
backend/media_lifecycle.py
Normal file
177
backend/media_lifecycle.py
Normal 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}
|
||||
|
|
@ -541,7 +541,8 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
|||
cur.execute(
|
||||
"""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.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
|
||||
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
|
||||
WHERE em.exercise_id = %s
|
||||
|
|
@ -1967,7 +1968,8 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
|
|||
cur.execute(
|
||||
"""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.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
|
||||
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
|
||||
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():
|
||||
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")
|
||||
media_root = get_effective_media_root(cur)
|
||||
abs_p = _resolve_local_media_file(
|
||||
|
|
@ -2116,9 +2124,9 @@ async def upload_exercise_media(
|
|||
"""SELECT id, storage_key, byte_size FROM media_assets
|
||||
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||
AND (club_id IS NOT DISTINCT FROM %s)
|
||||
AND lifecycle_state = 'active'
|
||||
AND lifecycle_state = %s
|
||||
LIMIT 1""",
|
||||
(full_sha, ex_vis, ex_club),
|
||||
(full_sha, ex_vis, ex_club, "active"),
|
||||
)
|
||||
existing_asset = cur.fetchone()
|
||||
|
||||
|
|
|
|||
60
backend/routers/media_assets.py
Normal file
60
backend/routers/media_assets.py
Normal 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")
|
||||
35
backend/scripts/media_retention_job.py
Normal file
35
backend/scripts/media_retention_job.py
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
Automatische Medien-Retention (Papierkorb Stufe 1→2, 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())
|
||||
74
backend/tests/test_platform_media_storage.py
Normal file
74
backend/tests/test_platform_media_storage.py
Normal 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"
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.41"
|
||||
APP_VERSION = "0.8.42"
|
||||
BUILD_DATE = "2026-05-07"
|
||||
DB_SCHEMA_VERSION = "20260507045"
|
||||
|
||||
|
|
@ -13,10 +13,11 @@ MODULE_VERSIONS = {
|
|||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"media_assets": "1.0.0", # POST /api/media-assets/{id}/lifecycle (Papierkorb §5)
|
||||
"groups": "0.1.0",
|
||||
"skills": "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_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -28,6 +29,15 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
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",
|
||||
"date": "2026-05-07",
|
||||
|
|
|
|||
|
|
@ -113,6 +113,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise
|
|||
if (!exercise) return null
|
||||
|
||||
const meta = metaParts(exercise)
|
||||
const visibleMedia = (exercise.media || []).filter((m) => {
|
||||
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
||||
return lc !== 'trash_hidden'
|
||||
})
|
||||
|
||||
return (
|
||||
<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} />
|
||||
</section>
|
||||
)}
|
||||
{(exercise.media || []).length > 0 && (
|
||||
{visibleMedia.length > 0 && (
|
||||
<section className="card" style={{ marginTop: '10px', padding: '12px 14px' }}>
|
||||
<h3 style={{ fontSize: '0.78rem', textTransform: 'uppercase', color: 'var(--text3)', marginBottom: '8px' }}>
|
||||
Medien
|
||||
</h3>
|
||||
{exercise.media.map((m) => (
|
||||
{visibleMedia.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: '12px' }}>
|
||||
<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>}
|
||||
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -151,6 +151,10 @@ function ExerciseDetailPage() {
|
|||
if (!exercise) return null
|
||||
|
||||
const meta = metaParts(exercise)
|
||||
const visibleMedia = (exercise.media || []).filter((m) => {
|
||||
const lc = String(m.asset_lifecycle_state || 'active').toLowerCase()
|
||||
return lc !== 'trash_hidden'
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="exercise-detail-shell" style={{ padding: '12px 12px 24px' }}>
|
||||
|
|
@ -211,12 +215,17 @@ function ExerciseDetailPage() {
|
|||
</section>
|
||||
)}
|
||||
|
||||
{(exercise.media || []).length > 0 && (
|
||||
{visibleMedia.length > 0 && (
|
||||
<section className="card exercise-detail-section">
|
||||
<h2>Medien</h2>
|
||||
{exercise.media.map((m) => (
|
||||
{visibleMedia.map((m) => (
|
||||
<div key={m.id} style={{ marginBottom: '1.25rem' }}>
|
||||
<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>}
|
||||
<MediaBlock media={m} exerciseId={exercise.id} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
if (!exerciseId) return
|
||||
const j = idx + dir
|
||||
|
|
@ -1321,6 +1331,82 @@ function ExerciseFormPage() {
|
|||
</>
|
||||
)}
|
||||
</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' }}>
|
||||
<input
|
||||
type="text"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
|
@ -1203,6 +1211,7 @@ export const api = {
|
|||
updateExerciseMedia,
|
||||
deleteExerciseMedia,
|
||||
reorderExerciseMedia,
|
||||
postMediaAssetLifecycle,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user