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)
|
**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.
|
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).
|
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 |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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 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/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)
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -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 |
|
| 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 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.
|
**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)
|
- `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**)
|
||||||
|
|
|
||||||
|
|
@ -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
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(
|
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()
|
||||||
|
|
||||||
|
|
|
||||||
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
|
# 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",
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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"
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user