Medienmanager und Sicherheitsupdate #21
|
|
@ -1,26 +1,33 @@
|
|||
# Shinkan Jinkendo - Projekt-Status
|
||||
|
||||
**Stand:** 2026-05-05
|
||||
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260505037`
|
||||
**Stand:** 2026-05-07
|
||||
**Version (Code):** 0.8.48 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
|
||||
**Branch:** develop
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + Slot‑Blueprint** (DB **036–037**): Rahmenkopf nur als Vorlage mit Kontext‑Stammdaten; pro Slot genau eine **Blueprint‑`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032–034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3–§4).
|
||||
**Aktueller Meilenstein (Medien):** **Medienbibliothek `/media`** ergänzt den Archiv-Picker: **Lifecycle-Filter** (aktiv / Papierkorb / ausgeblendet), **Copyright bearbeiten** (`PATCH`), **Vorschau** inkl. Papierkorb bei Verwaltungsrecht; API-Liste `copyright_notice`; zuvor **§4.2 Promotion official** (Übung + Medien).
|
||||
|
||||
**Letzte dokumentierte Änderungen (Mai 2026):**
|
||||
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
||||
|
||||
- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf.
|
||||
- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**.
|
||||
- ✅ APIs: erweiterte Rahmen‑Hydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4.
|
||||
- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`.
|
||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_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 — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
|
||||
|
||||
**Nächste Schritte (Auszug):**
|
||||
1. ~~**Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2)~~ — umgesetzt (0.8.47).
|
||||
2. ~~**Eigenständige Medienmanager-Seite**~~ — **Basis umgesetzt** (`/media`): Filter, Copyright, Lifecycle-Aktionen; Ausbau: Sichtbarkeit bearbeiten, Bulk, Quotas.
|
||||
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
|
||||
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
|
||||
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
|
||||
|
||||
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; kein Big-Bang vor stabiler Archiv-/Governance-Basis.
|
||||
|
||||
---
|
||||
|
||||
**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).
|
||||
|
|
@ -43,6 +50,7 @@
|
|||
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
|
||||
| **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
|
||||
| **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 |
|
||||
| **040–045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -95,6 +103,10 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### 🔲 In Arbeit / Backlog
|
||||
|
||||
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
|
||||
- [x] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2) — 0.8.47
|
||||
- [x] **Medien:** Medienbibliothek `/media` (Lifecycle-Filter, Copyright PATCH, Vorschau); Ausbau Manager/Bulk/S3 — Roadmap
|
||||
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
|
||||
- [ ] Admin-UI für Skill-Kategorien (CRUD) – falls noch offen
|
||||
- [ ] Responsive Design / Dark Mode / PWA
|
||||
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
|
||||
|
|
@ -125,7 +137,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### Dev
|
||||
|
||||
Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
Branch `develop`; Migrations bis mindestens **045** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
|
||||
### Prod
|
||||
|
||||
|
|
@ -146,7 +158,8 @@ 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) |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
|
||||
| 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 | ✅ Medien-Meilenstein aktualisiert |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -157,4 +170,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
**Letzte Aktualisierung:** 2026-05-07
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
## 7. Referenzen
|
||||
|
||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-06
|
||||
**Letzte Aktualisierung:** 2026-05-07
|
||||
|
|
|
|||
238
.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
Normal file
238
.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
Normal file
|
|
@ -0,0 +1,238 @@
|
|||
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
|
||||
|
||||
**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien
|
||||
**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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck und Abgrenzung
|
||||
|
||||
### 1.1 Zweck
|
||||
|
||||
- **Ein physisches Medium** („Datei-Asset“) wird **einmal** gespeichert und **mehrfach** mit Übungen verknüpft (Wiederverwendung, keine Dubletten auf der Platte).
|
||||
- **Sichtbarkeit** von Datei-Assets folgt **derselben Semantik** wie bei Übungen (`private` \| `club` \| `official` – ggf. spätere Enum-Erweiterungen nur gemeinsam mit Migration + ACCESS_LAYER-Doku).
|
||||
- **Superadmin** kann Medien **global** freigeben (fachlich: Stufe wie „offiziell“ / plattformweit lesbar gemäß `library_content_*`-Regeln).
|
||||
- **Copyright:** pro Asset sind **Vermerke** vorgesehen und an `official` / Vereinsnutzung anzubinden (Validierungsstufen in Umsetzung festlegen).
|
||||
- **Externer Speicher:** Speicherung darf **lokal/NAS** (Filesystem) oder **extern** (z. B. S3-kompatibler Dienst) erfolgen – die App arbeitet über eine **Speicher-Abstraktion** (Interface), nicht mit verstreuten `Path`-Annahmen.
|
||||
- **Papierkorb & Retention:** mehrstufiger Lebenszyklus mit **automatischen** und **manuellen** Übergängen, siehe §5.
|
||||
|
||||
### 1.2 Abgrenzung
|
||||
|
||||
- **Embeds** (reine externe URL, kein Upload) bleiben pro **Verknüpfung zur Übung** möglich (`embed_url` o. Ä.); sie haben **keinen** physischen Lebenszyklus wie Datei-Assets (kein Papierkorb „Stufe 3 physische Löschung“). Trotzdem: UI kann „Link ungültig“ o. Ä. separat abbilden.
|
||||
- **Übungs-Governance** (wer eine Übung bearbeiten darf) **bleibt maßgeblich** für **Anlegen/Entfernen/Umsortieren** von Medien **in dieser Übung**; Medien erzwingen **keine** schärfere Edit-Policy als die Übung selbst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Begriffe
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Media Asset** | Logische Entität für eine **hochgeladene Datei** inkl. Metadaten, Sichtbarkeit, Copyright, Speicherreferenz, Lifecycle-Status. |
|
||||
| **Übungs-Medium / Attachment** | Verknüpfung **Übung ↔ Asset** oder reines Embed; enthält u. a. `context` (Sektion), `sort_order`, übungsspezifischen Titel/Beschreibung, `is_primary`. |
|
||||
| **Medienmanager / Archiv** | Verwaltungs-UI/API für Assets (Suche, Metadaten, Lifecycle, ggf. Superadmin-global). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Zieldatenmodell (Überblick)
|
||||
|
||||
> Konkrete Tabellennamen/Migrationen beim Implementierungsstart festziehen; dieses Kapitel ist die **fachliche Norm**.
|
||||
|
||||
### 3.1 Tabelle `media_assets` (oder gleichwertiger Name)
|
||||
|
||||
**Pflichtidee:**
|
||||
|
||||
- Identität, technische Metadaten: `id`, `mime_type`, `byte_size`, `sha256` (vollständig), `original_filename`, `created_at`, `updated_at`.
|
||||
- Mandant & Governance: `visibility`, `club_id` (nullable je nach Semantik wie bei Übungen), `uploaded_by_profile_id`.
|
||||
- Copyright: mindestens `copyright_notice` (TEXT); optional `license`, `attribution`, `source_url`.
|
||||
- Speicher: `storage_backend` (Enum/String: z. B. `local`, `s3`, …), `storage_key` (relativer oder bucket-key), optional `storage_url` nur für interne Zwecke – **keine** öffentlichen Secrets in der DB.
|
||||
- Lifecycle: `lifecycle_state` (siehe §5), Timestamps für Übergänge und geplante Purge-Termine.
|
||||
|
||||
**Deduplizierung:** Innerhalb sinnvoller Grenze (z. B. **pro Verein** bei `club`, global nur mit Superadmin-Policy) über `sha256` + `club_id`/`visibility`; Konflikt bei Upload → bestehendes Asset verknüpfen oder expliziter Nutzerdialog (Umsetzungsdetail).
|
||||
|
||||
### 3.2 Verknüpfung (heute `exercise_media`)
|
||||
|
||||
- Erweiterung um **`media_asset_id`** (FK, nullable wenn reines Embed).
|
||||
- Embed-Zeilen: `media_asset_id IS NULL`, `embed_url`/`embed_platform` gesetzt wie heute.
|
||||
- Übungsspezifische Felder: `context` (`ablauf` \| `detail` \| `trainer_hint`), `sort_order`, `title`, `description`, `is_primary`.
|
||||
|
||||
### 3.3 Referenzen & physisches Löschen
|
||||
|
||||
- Vor **physischem** Löschen muss die Referenzanzahl aller aktiven/nicht-purged Links **0** sein (oder Quarantäne-Policy); siehe §5.3.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sichtbarkeit, Lesen und Übungs-Promotion
|
||||
|
||||
### 4.1 Leseregel Datei-Asset
|
||||
|
||||
- Zentrale Entscheidung analog `library_content_visible_to_profile` / TenantContext: **Profil darf Asset lesen** nur wenn Visibility+`club_id`+`created_by` zum Objekt passen (gleiche Philosophie wie Übungen).
|
||||
- **GET Download / Stream:** muss **Asset-Governance** prüfen; wenn der Aufruf **im Kontext einer Übung** erfolgt, zusätzlich (oder als Schnittmenge) **Übung lesbar** – verbindlich **ohne Seitenkanal** (kein Download nur mit Übungs-ID, wenn Asset für Nutzer unsichtbar wäre).
|
||||
|
||||
### 4.2 Promotion Übung → `official` (bzw. global)
|
||||
|
||||
Beim Speichern/Promoten einer Übung auf **`official`** (oder vergleichbare globale Stufe):
|
||||
|
||||
1. **Pflicht-Dialog:** Hinweis, dass **alle zugeordneten Datei-Assets**, die noch **nicht** diese Sichtbarkeit haben, **mit angehoben** werden (oder die Aktion schlägt fehl / erfordert Anpassung).
|
||||
2. **Abbruch:** Nutzer kann abbrechen oder einzelne Assets vor Freigabe anpassen / entkoppeln.
|
||||
3. **Copyright:** Für `official` sind leere oder unzureichende Copyright-Felder **nicht** akzeptabel (genaue Regel in Umsetzung + Validierung).
|
||||
|
||||
Superadmin kann Medien **explizit** global/offiziell machen (Archiv-Pfad), unabhängig von einer einzelnen Übung – konsistent zur Rolle.
|
||||
|
||||
### 4.3 Bearbeitung Medien in der Übung
|
||||
|
||||
Wer die Übung **bearbeiten** darf (bestehende oder künftig erweiterte Regel), darf für diese Übung:
|
||||
|
||||
- Medien **hinzufügen** (Upload neu oder Verknüpfung aus Archiv),
|
||||
- **Reihenfolge** / **Sektion** / **Titel** ändern,
|
||||
- **Verknüpfung** entfernen (Asset bleibt im Archiv, sofern nicht anderweitig gelöscht),
|
||||
- Embeds pflegen.
|
||||
|
||||
Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **global** zu Trash/ Purge zu bewegen, darf trotzdem die **Verknüpfung** in „seiner“ Übung lösen, solange die Übungs-Edit-Policy das erlaubt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Lebenszyklus (Papierkorb) – Norm
|
||||
|
||||
### 5.1 Zustände
|
||||
|
||||
| Zustand | Code (Beispiel) | Neue Zuordnung zu Übungen | Anzeige in Übung (Lesemodus) | Anzeige Bearbeiten |
|
||||
|---------|-----------------|---------------------------|------------------------------|-------------------|
|
||||
| Aktiv | `active` | ja | normal | normal |
|
||||
| Stufe 1 – Papierkorb | `trash_soft` | **nein** | ja + **Warnhinweis** („wird abgeschafft“) | ja + Warnhinweis |
|
||||
| Stufe 2 – Ausblendung | `trash_hidden` | nein | **nein** (Platzhalter/„nicht verfügbar“) | Recovery-Aktion sichtbar |
|
||||
| Stufe 3 – purgiert | `purged` / Zeile entfernt | nein | nein | nur noch historischer Hinweis nach Policy |
|
||||
|
||||
**Default-Retention (automatisch):**
|
||||
|
||||
- Stufe 1 → Stufe 2: **ca. 30 Tage** nach Eintritt in Stufe 1 (konfigurierbar).
|
||||
- Stufe 2 → Stufe 3 **physisches Löschen**: **ca. 90 Tage** (3 Monate) nach Eintritt in Stufe 2 (konfigurierbar).
|
||||
|
||||
**Manuell:** Berechtigte Rollen dürfen Übergänge **vorziehen** (Stufe 1 erzwingen, Stufe 2 erzwingen, Purge erzwingen) gemäß §5.2 – ggf. **Tier-Gates** (§6).
|
||||
|
||||
### 5.2 Wer darf Lifecycle-Transitionen?
|
||||
|
||||
| Aktion | Vereins-Asset (`club`, Owner-Verein) | Privates Asset (`private`, Uploader) | `official` / plattformweit |
|
||||
|--------|--------------------------------------|--------------------------------------|-----------------------------|
|
||||
| Stufe 1 (Papierkorb) | **Vereinsadmin** + **Superadmin** | **Uploader** + **Superadmin** | **Superadmin** |
|
||||
| Stufe 2 vorziehen / erzwingen | Vereinsadmin + Superadmin | Uploader + Superadmin | Superadmin |
|
||||
| Recovery Stufe 2 → 1 | wie Stufe 1-Eintritt, ggf. Tier | wie links | Superadmin |
|
||||
| Physisches Löschen (3) | Vereinsadmin + Superadmin; Systemjob nach Frist | Uploader + Superadmin | Superadmin |
|
||||
|
||||
**Superadmin / Systemadmin** ist immer **Override** (Klarstellung zur „systemadmin“-Formulierung).
|
||||
|
||||
### 5.3 Konsistenz mit bestehenden Übungen
|
||||
|
||||
- In **Stufe 1** bleiben Verknüpfungen funktional für Anzeige bestehen; Nutzer sehen **Warnung**.
|
||||
- In **Stufe 2** wird das Medium in der **öffentlichen Übungsansicht nicht gerendert**; im **Bearbeitungsmodus** gibt es einen **Recovery-Link**, der das Asset zurück auf **Stufe 1** setzt (Policy: nur wer nach §5.2 Stufe 1 setzen darf, oder erweiterte Regel – bei Konflikt ACCESS_LAYER + dieses Dokument anpassen).
|
||||
- **Physisches Löschen:** nur nach Stufe 2-Frist oder manueller Purge-Aktion; Backend entfernt Datei über Speicher-Backend und markiert Asset endgültig.
|
||||
|
||||
### 5.4 Hintergrundjobs
|
||||
|
||||
- Geplanter Job (täglich o. Ä.): Transitionen 1→2 und 2→3 gemäß Timestamps.
|
||||
- Alle Zeiten **konfigurierbar** (Umgebungsvariablen oder DB-Config), Defaults wie §5.1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tier & Features
|
||||
|
||||
- **Tier** kann erlauben oder verbieten: **manuelles** Vorziehen von Stufe 2 oder Purge, zusätzliche Speicherquoten, externes Backend, etc.
|
||||
- Tier ändert **nicht** die Übungs-Sichtbarkeit an sich; es **limitiert** nur Medien-spezifische Aktionen (Löschstufen, Archiv-Größe, …). Konkrete Feature-Keys in Umsetzung in `features` / `tier_limits` ergänzen und hier im **Changelog** dieses Dokuments verlinken.
|
||||
|
||||
---
|
||||
|
||||
## 7. Externe Speicherung (Server / S3)
|
||||
|
||||
- Abstraktion **StorageAdapter**: `put`, `get`, `delete`, ggf. `exists`.
|
||||
- Konfiguration über ENV (z. B. Endpoint, Bucket, Credentials) – nie im Frontend.
|
||||
- **NAS / anderer Rechner als App-Host:** In der Praxis `MEDIA_ROOT` in Compose/ENV auf den **Mount-Punkt** des NAS (oder NFS/SMB) setzen – die App läuft z. B. auf dem Raspberry, die Bytes liegen auf dem Speichersystem. Kein »Mediaserver« mit eigener Geschäftslogik nötig.
|
||||
- **Pfadkonvention** auf der Platte z. B. `clubs/{club_id}/…` für Mandantentrennung (Umsetzung schrittweise; neue Uploads können zuerst unter `exercises/{sha256}{ext}` liegen).
|
||||
|
||||
### 7.1 Konfiguration: Bootstrap vs. Superadmin (Laufzeit)
|
||||
|
||||
| Ebene | Inhalt | Wer / Wo |
|
||||
|-------|--------|----------|
|
||||
| **Bootstrap** | Basis-Verzeichnis `MEDIA_ROOT` (Container/Host), ggf. S3-Secrets | Deployment (`.env`, Docker Compose) – Container muss ohne UI starten können |
|
||||
| **Laufzeit (nicht-geheim)** | Zusätzlicher **relativer Unterpfad** unter `MEDIA_ROOT` (`local_relative_root`), später: aktives Backend `local` \| `s3`, öffentlicher Endpoint/Bucket-Name | **Superadmin** über Tabelle `platform_media_storage` + API; keine Secrets für S3 im Klartext in der DB (Keys nur ENV/Vault) |
|
||||
| **Effektives Wurzelverzeichnis** | `Path(MEDIA_ROOT) / local_relative_root` nach Normalisierung (kein `..`, kein absoluter Pfad im relativen Segment) | berechnet im Backend bei jedem Zugriff |
|
||||
|
||||
**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**. Änderungen an **Inline-Platzhaltern** (§11) → ebenfalls Changelog + ggf. Frontend-Sanitize-Regeln dokumentieren.
|
||||
|
||||
---
|
||||
|
||||
## 8. Embeds & Streaming-Plattformen
|
||||
|
||||
- Embeds **bleiben** erste Klasse; bestehende Plattform-Erkennung erweiterbar (`embed_platform`).
|
||||
- Roadmap: weitere Hosts (Player-Komponente, oEmbed, CSP) – **ohne** Änderung am Asset-Lifecycle-Modell.
|
||||
|
||||
---
|
||||
|
||||
## 9. Pflichten zur Drift-Vermeidung
|
||||
|
||||
| Änderung an … | Pflegepflicht |
|
||||
|---------------|---------------|
|
||||
| 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` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| 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 | **Medienmanager (Basis):** `GET /api/media-assets?lifecycle=…`, `copyright_notice` in Response; `PATCH` Copyright; `GET …/file` für Papierkorb-Zeilen bei Verwaltungsrecht; UI-Route `/media` (Medienbibliothek). |
|
||||
|
||||
---
|
||||
|
||||
## 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)
|
||||
- `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
- `backend/routers/exercises.py` – Ist-Zustand `exercise_media` bis Refactor
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
**Autor:** Claude Code
|
||||
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
|
||||
|
||||
> **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.
|
||||
|
||||
---
|
||||
|
||||
## 1. Upload-Strategie
|
||||
|
|
|
|||
|
|
@ -11,21 +11,39 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
|
||||
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
|
||||
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
|
||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | |
|
||||
| exercises | `GET .../media/{mid}/file` | ja | `get_tenant_context_flexible` | ja (wie Übung lesen) | Datei oder `?ssetoken`; kein anonymes `/media/` ohne ALLOW_PUBLIC_MEDIA_STATIC |
|
||||
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | PUT Einzelübung: bei Sichtbarkeit `official` Medien-§4.2 (422: Lifecycle/Promotion/Copyright) |
|
||||
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
|
||||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage 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` |
|
||||
| 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 | u. a. `trash_soft` mit Trainer-nur-privat-Eigentum; `purge` nur **Superadmin**; Superadmin: `superadmin_force_lifecycle`, `superadmin_hard_delete` |
|
||||
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets |
|
||||
| media_assets | `POST /api/media-assets/bulk-lifecycle` | ja | `get_tenant_context` | ja | Mehrfach-Lifecycle; gleiche Regeln wie Einzel-POST |
|
||||
| media_assets | `POST /api/media-assets/bulk-patch` | ja | `get_tenant_context` | ja | Copyright / Bezeichner / Sichtbarkeit für viele IDs; gemischte Fehler in `failed[]` |
|
||||
| media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Copyright, `original_filename`, optional `visibility`/`club_id`; Rechte pro Stufe |
|
||||
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung |
|
||||
| exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv |
|
||||
| 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 |
|
||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin | EXEMPT |
|
||||
| maturity_models | Admin-Matrix | nein (global) | `require_auth` | Admin für Schreiben; `GET …/{id}` nur Portal-Admin | EXEMPT |
|
||||
| matrix_stack_bundle | Export/Import Bundles | Plattform/Test | `require_auth` | Admin | EXEMPT |
|
||||
| import_wiki / import_wiki_admin | Wiki-Import | Werkzeug | `require_auth`/Admin | Admin | EXEMPT |
|
||||
|
||||
**Legende:** Router auf der EXEMPT-Liste des Scripts sind globale oder Auth-only-Pfade; sobald ein Router Vereinsdaten oder Bibliotheks-Sichtbarkeit erhält, EXEMPT entfernen und `get_tenant_context` einführen.
|
||||
|
||||
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
|
||||
**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 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung.
|
||||
|
||||
---
|
||||
|
||||
### Changelog (Fortführung)
|
||||
|
||||
- **2026-05-07:** Legacy `GET/PUT /api/profile` auf Session-Profil gehärtet; OpenAPI/Health-Ready Produktionsdefaults; Security-Release-Tests + CI-Schritt `security_release_checks.py` — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
- **2026-05-07 (Phase 3):** CSP SPA (nginx); API `nosniff`-Middleware — siehe `PRODUCTION_READINESS_AUDIT_2026-05.md`.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
95
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
95
.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
# Produktionsreife: Audit-Ergebnis & Umsetzungsplan (Stand 2026-05-07, Phase-3-Update)
|
||||
|
||||
**Zweck:** Einheitliche Referenz gegen **Drift** zwischen Sicherheits-/Betriebsanforderungen und Code.
|
||||
**Bezug:** Zugriffsschicht `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, Cursor-Regel `.cursor/rules/access-layer.mdc`.
|
||||
|
||||
---
|
||||
|
||||
## Teil A — Audit-Ergebnis (Kurzfassung)
|
||||
|
||||
### A.1 Kritisch / Hoch (behoben oder geplant)
|
||||
|
||||
| ID | Befund | Status (2026-05-07) |
|
||||
|----|--------|----------------------|
|
||||
| SEC-01 | `GET /api/profile` (Legacy): ohne Header wurde das **erste Profil der DB** geliefert → IDOR / Datenleck | **Behoben:** Profil immer aus Session |
|
||||
| SEC-02 | OpenAPI `/docs`, `/redoc`, `/openapi.json` in Produktion exponiert | **Behoben:** bei `ENVIRONMENT=production` aus; Override `PUBLIC_OPENAPI=1` |
|
||||
| SEC-03 | `/api/health/ready` mit Tabellendetails / Migrationszähler öffentlich | **Behoben:** in Prod nur kompakte Antwort; Detail via `HEALTH_READY_PUBLIC_DETAIL=1` |
|
||||
| SEC-04 | Statische Auslieferung `/media` ohne Auth (Link-Leak) | **Behoben:** `GET /api/exercises/.../media/.../file` mit Governance + `ssetoken`; `/media` nur mit `ALLOW_PUBLIC_MEDIA_STATIC=1` |
|
||||
| OPS-01 | PostgreSQL-Hostport in `docker-compose.yml` | **Behoben:** Bind `127.0.0.1:5434` (nur Host-Local) |
|
||||
|
||||
### A.2 Mittel / Konsistenz
|
||||
|
||||
| ID | Befund | Status |
|
||||
|----|--------|--------|
|
||||
| CON-01 | Frontend: direktes `fetch('/api/profiles')` ohne zentralen Client | **Behoben** (AdminCatalogsPage → `api.listProfiles`) |
|
||||
| CON-02 | `ProfileContext.jsx` (unused): falsches Token-Feld / `session` | **Behoben** (`user` + `getCurrentProfile` / `listProfiles`) |
|
||||
| MEM-01 | Vereinsmitgliederverzeichnis zeigt E-Mail an alle Mitglieder | **Behoben:** E-Mail nur Plattform-Admin / `can_manage_club_org` |
|
||||
| MAT-01 | `GET /maturity-models/{id}` für jeden Auth-Nutzer | **Behoben:** nur Portal-Admin (UI nutzt ohnehin nur Admin-Panel) |
|
||||
|
||||
### A.3 Niedrig / Offen
|
||||
|
||||
| ID | Befund | Status |
|
||||
|----|--------|--------|
|
||||
| CON-03 | Sparten (`division`) vs. Zielbild ACCESS_LAYER | **Roadmap** — siehe Phase 3 unten (Stufe D); nicht in diesem Schritt implementiert |
|
||||
| OPS-02 | CSP / restliche Browser-Härtung | **Behoben (Basis):** CSP auf SPA-Dokumente (nginx `location /`); API `X-Content-Type-Options: nosniff` (FastAPI-Middleware) |
|
||||
| MISC-01 | `auth.py` Debug-`print` beim Import | **Behoben** (entfernt) |
|
||||
| MISC-02 | `delete_profile`: DELETE auf Mitai-Tabellen schlägt fehl, wenn Tabelle fehlt | **Behoben:** nur löschen, wenn Tabelle existiert |
|
||||
|
||||
---
|
||||
|
||||
## Teil B — Priorisierter Umsetzungsplan
|
||||
|
||||
### Phase 1 — Sofort (Beta-Blocker) ✅ umgesetzt in Repo-Stand
|
||||
|
||||
1. Legacy-Endpunkte `/api/profile` GET/PUT: **nur Session-`profile_id`**, kein „erstes Profil“-Fallback.
|
||||
2. OpenAPI in Produktion deaktivieren; opt-in für Notfall-Debugging.
|
||||
3. Readiness-Endpoint in Prod entschärfen; operatives Detail per Env.
|
||||
4. Automatisierte **Security-Release-Tests** (`tests/test_security_release.py`) + Script `scripts/security_release_checks.py` in CI.
|
||||
5. Profil-Löschung: optionale Tabellen nur bei Existenz.
|
||||
6. Frontend: Admin-Kataloge Profilliste über `api.js`.
|
||||
|
||||
### Phase 2 — Kurzfristig (vor breitem Go-Live) ✅ Hauptpunkte umgesetzt (2026-05-07)
|
||||
|
||||
1. **Medien:** `GET /api/exercises/{id}/media/{mid}/file` — Auth (`X-Auth-Token` oder `?ssetoken=`), Zugriff wie GET Übung; statisches `/media` standardmäßig aus.
|
||||
2. **Mitgliederverzeichnis:** E-Mail nur wenn Plattform-Admin oder `can_manage_club_org`.
|
||||
3. **DB-Port:** Prod-Compose Postgres an `127.0.0.1` gebunden.
|
||||
4. **`GET /api/maturity-models/{id}`:** nur Portal-Admin (Liste/resolve unverändert für Trainer).
|
||||
|
||||
### Phase 3 — Mittelfristig
|
||||
|
||||
1. **CSP & Header:** Content-Security-Policy für die SPA (nginx, `location /`); `X-Content-Type-Options: nosniff` auf allen FastAPI-Responses — ✅ umgesetzt.
|
||||
2. **Audit-Pflege:** bei Tenant/Governance-Änderungen `ACCESS_LAYER_ENDPOINT_AUDIT.md` und dieses Dokument anpassen — fortlaufend (Hinweis im Endpoint-Audit).
|
||||
3. **Division / Sparten (CON-03, Stufe D):** noch **nicht** code-seitig durchgesetzt. Nächste technische Schritte (für eigenes Epic):
|
||||
- Ist-Zustand: `division_id` auf Objekten & `division_lead` in `can_plan_in_club` ohne strikte Objekt-Filter.
|
||||
- Ziel: Lesen/Schreiben nur mit passender Sparten-Mitgliedschaft bzw. Rolle; einheitliche Filter in Listen (`training_groups`, Übungen, Planung).
|
||||
- Tests: Zwei Nutzer, zwei Sparten, kein Cross-Lesen; Superadmin-Pfade getrennt.
|
||||
- Spec: `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe D.
|
||||
|
||||
---
|
||||
|
||||
## Teil C — CI / Qualitätssicherung
|
||||
|
||||
- **ACCESS_LAYER_STRICT:** `scripts/check_access_layer_hints.py` (unverändert Pflicht).
|
||||
- **Security-Release:** `python scripts/security_release_checks.py` (pytest-Modul `test_security_release`).
|
||||
- **Regression Profil:** `tests/test_profiles_read_access.py` (Legacy-Route).
|
||||
|
||||
---
|
||||
|
||||
## Teil D — Umgebungsvariablen (Referenz)
|
||||
|
||||
| Variable | Bedeutung |
|
||||
|----------|-----------|
|
||||
| `ENVIRONMENT` | `production` / `prod` → OpenAPI aus, Health-Ready kompakt |
|
||||
| `PUBLIC_OPENAPI` | `1` / `true` → OpenAPI trotz Prod einschalten (nur Debugging) |
|
||||
| `HEALTH_READY_PUBLIC_DETAIL` | `1` / `true` → volle Ready-JSON inkl. Tabellenliste in Prod |
|
||||
| `ALLOW_PUBLIC_MEDIA_STATIC` | `1` / `true` → öffentliches Mount von `/media/` (Notfall/Legacy; Standard: aus) |
|
||||
| `CSP` (nginx) | Kein Env: Policy fest in `frontend/nginx.conf` (`location /`). API auf **anderer Host-URL** als SPA: `connect-src` in der Policy erweitern oder Build mit envsubst. |
|
||||
|
||||
---
|
||||
|
||||
**Pflege:** Nach jeder relevanten Änderung **Status-Spalte** und ggf. **Phase** aktualisieren; bei neuem Audit Datum im Titel anpassen oder Abschnitt „Changelog“ ergänzen.
|
||||
|
||||
### Changelog
|
||||
|
||||
- **2026-05-07:** Phase 2 Medien, Mitgliederverzeichnis E-Mail, maturity GET admin-only, Postgres localhost bind, Tests `test_exercise_media_download.py`.
|
||||
- **Phase 3:** CSP (nginx SPA), API `X-Content-Type-Options` (FastAPI), Test `test_api_attachments_x_content_type_options_nosniff`; Division-Durchsetzung nur als Roadmap (CON-03).
|
||||
|
|
@ -58,6 +58,7 @@ return {"message": "Fehler", "success": False}
|
|||
### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER)
|
||||
|
||||
**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||
**Medien-Assets (Archiv, Papierkorb, Promotion, Copyright, externer Speicher):** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
|
||||
**Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
|
||||
**Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen:
|
||||
|
|
|
|||
|
|
@ -38,6 +38,7 @@ jobs:
|
|||
pip install -r /app/requirements-dev.txt &&
|
||||
cd /app &&
|
||||
ACCESS_LAYER_STRICT=1 python scripts/check_access_layer_hints.py &&
|
||||
python scripts/security_release_checks.py &&
|
||||
ACCESS_LAYER_INTEGRATION=1 SKIP_DB_MIGRATE=1 python -m pytest tests -m 'not slow' -ra -vv --tb=short
|
||||
"
|
||||
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||
> | Medien-Archiv, Lifecycle, Promotion | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
@ -105,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**)
|
||||
|
|
@ -200,6 +201,10 @@ ALLOWED_ORIGINS=https://shinkan.jinkendo.de
|
|||
MEDIA_DIR=/app/media
|
||||
```
|
||||
|
||||
## Produktions-/Sicherheitsaudit (Drift vermeiden)
|
||||
|
||||
Aktuelle Befunde und Umsetzungsstände: `.claude/docs/working/PRODUCTION_READINESS_AUDIT_2026-05.md` (Fortlaufend pflegen.)
|
||||
|
||||
## Kritische Regeln für Claude Code
|
||||
|
||||
### Must-Do:
|
||||
|
|
|
|||
|
|
@ -13,8 +13,6 @@ import bcrypt
|
|||
|
||||
from db import get_db, get_cursor
|
||||
|
||||
print("[AUTH.PY] Module loaded - require_auth_flexible will be defined")
|
||||
|
||||
|
||||
def hash_pin(pin: str) -> str:
|
||||
"""Hash password with bcrypt. Falls back gracefully from legacy SHA256."""
|
||||
|
|
|
|||
|
|
@ -11,6 +11,10 @@ def is_platform_admin(role: Optional[str]) -> bool:
|
|||
return (role or "").lower() in ("admin", "superadmin")
|
||||
|
||||
|
||||
def is_superadmin(role: Optional[str]) -> bool:
|
||||
return (role or "").lower() == "superadmin"
|
||||
|
||||
|
||||
def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
|
||||
cur.execute(
|
||||
"""
|
||||
|
|
@ -125,6 +129,24 @@ def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> Li
|
|||
return out
|
||||
|
||||
|
||||
def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> Set[int]:
|
||||
"""Vereins-IDs, in denen das Profil mindestens eine der Rollen hat."""
|
||||
if not role_codes:
|
||||
return set()
|
||||
ph = ",".join(["%s"] * len(role_codes))
|
||||
cur.execute(
|
||||
f"""
|
||||
SELECT DISTINCT cm.club_id
|
||||
FROM club_members cm
|
||||
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
|
||||
WHERE cm.profile_id = %s AND cm.status = 'active'
|
||||
AND r.role_code IN ({ph})
|
||||
""",
|
||||
(profile_id, *role_codes),
|
||||
)
|
||||
return {int(r["club_id"]) for r in cur.fetchall() if r.get("club_id") is not None}
|
||||
|
||||
|
||||
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung
|
|||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import FastAPI
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
|
|
@ -18,6 +18,20 @@ from slowapi.errors import RateLimitExceeded
|
|||
|
||||
from version import APP_VERSION, BUILD_DATE, DB_SCHEMA_VERSION, MODULE_VERSIONS
|
||||
|
||||
|
||||
def _is_production_environment() -> bool:
|
||||
return os.getenv("ENVIRONMENT", "development").strip().lower() in ("production", "prod")
|
||||
|
||||
|
||||
def _public_openapi_enabled() -> bool:
|
||||
return os.getenv("PUBLIC_OPENAPI", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
def _health_ready_public_detail_enabled() -> bool:
|
||||
"""In Prod standardmäßig keine Tabellen-/Migrations-Details (Information Disclosure)."""
|
||||
return os.getenv("HEALTH_READY_PUBLIC_DETAIL", "").strip().lower() in ("1", "true", "yes")
|
||||
|
||||
|
||||
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start
|
||||
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
|
||||
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
|
||||
|
|
@ -39,11 +53,20 @@ else:
|
|||
|
||||
from routers.auth import limiter as auth_rate_limiter
|
||||
|
||||
# OpenAPI: in Produktion standardmäßig aus (Schema nicht öffentlich). Notfall: PUBLIC_OPENAPI=1
|
||||
_expose_docs = (not _is_production_environment()) or _public_openapi_enabled()
|
||||
_openapi_url = "/openapi.json" if _expose_docs else None
|
||||
_docs_url = "/docs" if _expose_docs else None
|
||||
_redoc_url = "/redoc" if _expose_docs else None
|
||||
|
||||
# Initialize FastAPI app
|
||||
app = FastAPI(
|
||||
title="Shinkan Jinkendo API",
|
||||
description="Trainer- und Vereinsplattform für Kampfsport-Trainingsplanung",
|
||||
version=APP_VERSION
|
||||
version=APP_VERSION,
|
||||
openapi_url=_openapi_url,
|
||||
docs_url=_docs_url,
|
||||
redoc_url=_redoc_url,
|
||||
)
|
||||
|
||||
# SlowAPI: Rate Limits auf /api/auth/* (Decorator in routers/auth.py)
|
||||
|
|
@ -62,7 +85,13 @@ app.add_middleware(
|
|||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# TODO: Initialize Database with migrations
|
||||
|
||||
@app.middleware("http")
|
||||
async def add_api_security_headers(request: Request, call_next):
|
||||
"""Konsistente Basis-Header auch für rein JSON-Responses (MIME-Sniffing)."""
|
||||
response = await call_next(request)
|
||||
response.headers.setdefault("X-Content-Type-Options", "nosniff")
|
||||
return response
|
||||
|
||||
# Version Endpoint (public, no auth)
|
||||
@app.get("/api/version")
|
||||
|
|
@ -132,7 +161,7 @@ def health_ready():
|
|||
migration_count = 0
|
||||
|
||||
complete = bool(err is None and all(tables.get(t) for t in REQUIRED))
|
||||
return {
|
||||
body = {
|
||||
"status": "ready" if complete else "degraded",
|
||||
"database": err is None,
|
||||
"detail": err,
|
||||
|
|
@ -140,21 +169,30 @@ def health_ready():
|
|||
"tables": tables,
|
||||
"schema_migrations_count": migration_count,
|
||||
}
|
||||
if _is_production_environment() and not _health_ready_public_detail_enabled():
|
||||
return {
|
||||
"status": body["status"],
|
||||
"database": body["database"],
|
||||
"schema_complete": body["schema_complete"],
|
||||
}
|
||||
return body
|
||||
|
||||
|
||||
# Root Endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
"""Root endpoint - API info"""
|
||||
return {
|
||||
out = {
|
||||
"app": "Shinkan Jinkendo API",
|
||||
"version": APP_VERSION,
|
||||
"docs": "/docs",
|
||||
"health": "/health"
|
||||
"health": "/health",
|
||||
}
|
||||
if _expose_docs:
|
||||
out["docs"] = "/docs"
|
||||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, 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)
|
||||
|
|
@ -164,6 +202,8 @@ app.include_router(clubs.router)
|
|||
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)
|
||||
|
|
@ -173,10 +213,13 @@ app.include_router(matrix_stack_bundle.router)
|
|||
app.include_router(import_wiki.router)
|
||||
app.include_router(import_wiki_admin.router)
|
||||
|
||||
# Lokale Medien (Übungen-Uploads) unter MEDIA_ROOT, ausliefern unter /media/...
|
||||
# Lokale Übungs-Medien: standardmäßig nur über geschützten API-Pfad
|
||||
# GET /api/exercises/{id}/media/{mid}/file (?ssetoken für <img>/<video>).
|
||||
# Notfall/Legacy: ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder öffentlich unter /media/
|
||||
_media_dir = os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media"))
|
||||
Path(_media_dir).mkdir(parents=True, exist_ok=True)
|
||||
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
|
||||
if os.getenv("ALLOW_PUBLIC_MEDIA_STATIC", "").strip().lower() in ("1", "true", "yes"):
|
||||
app.mount("/media", StaticFiles(directory=_media_dir), name="media")
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
|
|
|
|||
296
backend/media_lifecycle.py
Normal file
296
backend/media_lifecycle.py
Normal file
|
|
@ -0,0 +1,296 @@
|
|||
"""
|
||||
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, is_superadmin
|
||||
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:
|
||||
"""
|
||||
Papierkorb Stufe 2 / Recovery / Reaktivierung — nicht für trash_soft (siehe assert_can_trash_soft).
|
||||
§5.2: official nur Plattform-Admin; club Vereinsorga; privat nur Uploader.
|
||||
"""
|
||||
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 assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None:
|
||||
"""
|
||||
Aktiv → Papierkorb (Stufe 1). Trainer: nur eigene private Uploads.
|
||||
Vereinsmedien: Vereinsorga; official: Plattform-Admin; Superadmin: immer.
|
||||
"""
|
||||
role_raw = tenant.global_role
|
||||
role = (role_raw or "").strip().lower()
|
||||
if is_superadmin(role):
|
||||
return
|
||||
if is_platform_admin(role):
|
||||
return
|
||||
vis = (asset.get("visibility") or "private").strip().lower()
|
||||
uid = asset.get("uploaded_by_profile_id")
|
||||
cid = asset.get("club_id")
|
||||
pid = int(tenant.profile_id)
|
||||
if vis == "private":
|
||||
if uid is not None and int(uid) == pid:
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur eigene private Medien dürfen in den Papierkorb",
|
||||
)
|
||||
if vis == "club":
|
||||
if cid is not None and can_manage_club_org(cur, pid, int(cid), role_raw):
|
||||
return
|
||||
raise HTTPException(
|
||||
status_code=403,
|
||||
detail="Nur Vereinsorganisation darf Vereinsmedien in den Papierkorb legen",
|
||||
)
|
||||
if vis == "official":
|
||||
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
|
||||
|
||||
|
||||
def assert_can_edit_media_asset_metadata(cur: Any, tenant: Any, asset: dict) -> None:
|
||||
"""PATCH Metadaten / Sichtbarkeit — gleiche Stufen wie Lifecycle (ohne Papierkorb)."""
|
||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
||||
|
||||
|
||||
def superadmin_force_lifecycle_state(cur: Any, conn: Any, asset_id: int, target: str) -> dict:
|
||||
"""Nur Superadmin: Zustand direkt setzen."""
|
||||
if target not in (LC_ACTIVE, LC_TRASH_SOFT, LC_TRASH_HIDDEN):
|
||||
raise HTTPException(status_code=400, detail="Ungültiger Ziel-Lifecycle")
|
||||
if target == LC_ACTIVE:
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET lifecycle_state = %s, updated_at = NOW(),
|
||||
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
|
||||
WHERE id = %s
|
||||
RETURNING id, lifecycle_state""",
|
||||
(LC_ACTIVE, asset_id),
|
||||
)
|
||||
elif target == LC_TRASH_SOFT:
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET lifecycle_state = %s, trash_soft_at = COALESCE(trash_soft_at, NOW()),
|
||||
trash_hidden_at = NULL, purge_after_at = NULL, updated_at = NOW()
|
||||
WHERE id = %s
|
||||
RETURNING id, lifecycle_state, trash_soft_at""",
|
||||
(LC_TRASH_SOFT, asset_id),
|
||||
)
|
||||
else:
|
||||
pa = datetime.now(timezone.utc) + timedelta(days=HIDDEN_TO_PURGE_DAYS)
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET lifecycle_state = %s, trash_hidden_at = NOW(),
|
||||
purge_after_at = %s, updated_at = NOW(),
|
||||
trash_soft_at = COALESCE(trash_soft_at, NOW())
|
||||
WHERE id = %s
|
||||
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
|
||||
(LC_TRASH_HIDDEN, pa, asset_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
conn.commit()
|
||||
return r2d(row)
|
||||
|
||||
|
||||
def superadmin_hard_delete_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
|
||||
"""Nur Superadmin: Zeile + Datei unabhängig vom Lifecycle entfernen."""
|
||||
cur.execute(
|
||||
"SELECT id, storage_key FROM media_assets WHERE id = %s",
|
||||
(asset_id,),
|
||||
)
|
||||
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 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 reactivate_media_asset_from_trash(cur: Any, conn: Any, asset_id: int) -> dict:
|
||||
"""
|
||||
Stufe 1 oder 2 → wieder aktiv (z. B. erneuter Upload derselben Datei / explizite Wiederherstellung).
|
||||
"""
|
||||
cur.execute(
|
||||
"""UPDATE media_assets
|
||||
SET lifecycle_state = %s, updated_at = NOW(),
|
||||
trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL
|
||||
WHERE id = %s AND lifecycle_state IN (%s, %s)
|
||||
RETURNING id, lifecycle_state""",
|
||||
(LC_ACTIVE, asset_id, LC_TRASH_SOFT, LC_TRASH_HIDDEN),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur Medien aus dem Papierkorb (Stufe 1 oder 2) können reaktiviert werden",
|
||||
)
|
||||
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}
|
||||
57
backend/media_storage.py
Normal file
57
backend/media_storage.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Effektives Medien-Wurzelverzeichnis (MEDIA_ROOT + Superadmin-relativer Pfad). Siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §7.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _default_media_root() -> Path:
|
||||
return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")))
|
||||
|
||||
|
||||
def normalize_local_relative_root(raw: str) -> str:
|
||||
s = (raw or "").strip().replace("\\", "/")
|
||||
s = s.strip("/")
|
||||
if not s:
|
||||
return ""
|
||||
if ".." in s.split("/"):
|
||||
raise ValueError("Pfad darf nicht '..' enthalten")
|
||||
if s.startswith("/"):
|
||||
raise ValueError("Nur relativer Pfad erlaubt")
|
||||
return s
|
||||
|
||||
|
||||
def get_effective_media_root(cur: Any) -> Path:
|
||||
"""
|
||||
MEDIA_ROOT aus ENV mit optionalem local_relative_root aus platform_media_storage (id=1).
|
||||
"""
|
||||
base = _default_media_root().resolve()
|
||||
rel = ""
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT local_relative_root FROM platform_media_storage WHERE id = 1",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is not None:
|
||||
v = row["local_relative_root"] if isinstance(row, dict) else row[0]
|
||||
rel = normalize_local_relative_root(str(v or ""))
|
||||
except Exception:
|
||||
rel = ""
|
||||
if not rel:
|
||||
return base
|
||||
return (base / rel).resolve()
|
||||
|
||||
|
||||
def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
|
||||
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
|
||||
key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
|
||||
if not key or ".." in key.split("/"):
|
||||
return None
|
||||
p = (media_root / key).resolve()
|
||||
try:
|
||||
p.relative_to(media_root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
106
backend/migrations/045_media_assets_and_platform_storage.sql
Normal file
106
backend/migrations/045_media_assets_and_platform_storage.sql
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
-- Migration 045: Zentrale media_assets, exercise_media.media_asset_id, platform_media_storage (Superadmin-Pfad).
|
||||
-- Siehe .claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE platform_media_storage (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1,
|
||||
CONSTRAINT platform_media_storage_singleton CHECK (id = 1),
|
||||
storage_backend VARCHAR(32) NOT NULL DEFAULT 'local',
|
||||
local_relative_root VARCHAR(512) NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO platform_media_storage (id, storage_backend, local_relative_root)
|
||||
VALUES (1, 'local', '')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE media_assets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mime_type VARCHAR(100),
|
||||
byte_size INT,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
original_filename VARCHAR(300),
|
||||
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
uploaded_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
copyright_notice TEXT,
|
||||
storage_backend VARCHAR(32) NOT NULL DEFAULT 'local',
|
||||
storage_key TEXT NOT NULL,
|
||||
lifecycle_state VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
trash_soft_at TIMESTAMP WITH TIME ZONE,
|
||||
trash_hidden_at TIMESTAMP WITH TIME ZONE,
|
||||
purge_after_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_media_assets_club ON media_assets(club_id);
|
||||
CREATE INDEX idx_media_assets_sha256 ON media_assets(sha256);
|
||||
CREATE INDEX idx_media_assets_lifecycle ON media_assets(lifecycle_state);
|
||||
CREATE UNIQUE INDEX ux_media_assets_storage_key ON media_assets(storage_key);
|
||||
|
||||
ALTER TABLE exercise_media
|
||||
ADD COLUMN IF NOT EXISTS media_asset_id INT REFERENCES media_assets(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_media_asset ON exercise_media(media_asset_id);
|
||||
|
||||
-- Bestand: je distinct storage_key ein Asset; sha256 aus DB-Pfad (Migrations-Platzhalter, nicht Content-Hash)
|
||||
INSERT INTO media_assets (
|
||||
mime_type,
|
||||
byte_size,
|
||||
sha256,
|
||||
original_filename,
|
||||
visibility,
|
||||
club_id,
|
||||
uploaded_by_profile_id,
|
||||
storage_backend,
|
||||
storage_key,
|
||||
copyright_notice,
|
||||
lifecycle_state,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
s.mime_type,
|
||||
s.byte_size,
|
||||
s.sha256,
|
||||
s.original_filename,
|
||||
s.visibility,
|
||||
s.club_id,
|
||||
s.uploaded_by_profile_id,
|
||||
'local',
|
||||
s.storage_key,
|
||||
NULL,
|
||||
'active',
|
||||
s.created_at,
|
||||
NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT ON (storage_key)
|
||||
em.mime_type,
|
||||
em.file_size AS byte_size,
|
||||
encode(digest(trim(em.file_path), 'sha256'), 'hex') AS sha256,
|
||||
em.original_filename,
|
||||
lower(trim(e.visibility)) AS visibility,
|
||||
e.club_id,
|
||||
e.created_by AS uploaded_by_profile_id,
|
||||
regexp_replace(trim(em.file_path), '^/+media/+', '') AS storage_key,
|
||||
em.created_at
|
||||
FROM exercise_media em
|
||||
JOIN exercises e ON e.id = em.exercise_id
|
||||
WHERE em.file_path IS NOT NULL
|
||||
AND trim(em.file_path) <> ''
|
||||
AND em.embed_url IS NULL
|
||||
ORDER BY regexp_replace(trim(em.file_path), '^/+media/+', ''), em.id
|
||||
) s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM media_assets ma WHERE ma.storage_key = s.storage_key);
|
||||
|
||||
UPDATE exercise_media em
|
||||
SET media_asset_id = ma.id
|
||||
FROM media_assets ma
|
||||
WHERE em.media_asset_id IS NULL
|
||||
AND em.file_path IS NOT NULL
|
||||
AND trim(em.file_path) <> ''
|
||||
AND em.embed_url IS NULL
|
||||
AND ma.storage_key = regexp_replace(trim(em.file_path), '^/+media/+', '');
|
||||
|
|
@ -74,9 +74,10 @@ def public_club_directory():
|
|||
return [r2d(r) for r in cur.fetchall()]
|
||||
|
||||
|
||||
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (jeder Vereinsmitglied) ──
|
||||
# ── Aktive Mitglieder für Trainer-/Co-Trainer-Auswahl (Vereinsmitglied) ──
|
||||
@router.get("/clubs/{club_id}/members/directory")
|
||||
def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_tenant_context)):
|
||||
"""id + name für alle aktiven Mitglieder; E-Mail nur für Plattform-Admin oder Vereinsadmin (Org-Verwaltung)."""
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
with get_db() as conn:
|
||||
|
|
@ -96,7 +97,12 @@ def club_members_directory(club_id: int, tenant: TenantContext = Depends(get_ten
|
|||
""",
|
||||
(club_id,),
|
||||
)
|
||||
return [r2d(r) for r in cur.fetchall()]
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
show_email = is_platform_admin(role) or can_manage_club_org(cur, profile_id, club_id, role)
|
||||
if not show_email:
|
||||
for d in rows:
|
||||
d["email"] = None
|
||||
return rows
|
||||
|
||||
|
||||
# ── Get Club ──────────────────────────────────────────────────────────
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -3,7 +3,8 @@ Reifegradmodelle / Fähigkeitsmatrix (kontextbezogen)
|
|||
|
||||
Kontext zu Fokusbereich, Stilrichtung, Zielgruppe: jeweils M:N (leer = gilt überall).
|
||||
|
||||
Lesen: alle authentifizierten Nutzer.
|
||||
Lesen: Liste & resolve für alle authentifizierten Nutzer; GET eines Modells nach ID nur Portal-Admin (Admin-UI).
|
||||
|
||||
Schreiben: admin, superadmin.
|
||||
"""
|
||||
from datetime import datetime, timezone
|
||||
|
|
@ -534,6 +535,7 @@ def list_maturity_models(
|
|||
|
||||
@router.get("/maturity-models/{model_id}")
|
||||
def get_maturity_model(model_id: int, session: dict = Depends(require_auth)):
|
||||
_require_admin(session)
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return _load_full_model(cur, model_id)
|
||||
|
|
|
|||
552
backend/routers/media_assets.py
Normal file
552
backend/routers/media_assets.py
Normal file
|
|
@ -0,0 +1,552 @@
|
|||
"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC."""
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field, model_validator
|
||||
|
||||
from club_tenancy import (
|
||||
assert_valid_governance_visibility,
|
||||
club_ids_for_profile_with_roles,
|
||||
is_platform_admin,
|
||||
is_superadmin,
|
||||
library_content_visible_to_profile,
|
||||
)
|
||||
from db import get_db, get_cursor, r2d
|
||||
from media_lifecycle import (
|
||||
LC_ACTIVE,
|
||||
LC_TRASH_HIDDEN,
|
||||
LC_TRASH_SOFT,
|
||||
assert_can_edit_media_asset_metadata,
|
||||
assert_can_manage_media_asset_lifecycle,
|
||||
assert_can_trash_soft,
|
||||
fetch_media_asset_row,
|
||||
purge_media_asset,
|
||||
reactivate_media_asset_from_trash,
|
||||
superadmin_force_lifecycle_state,
|
||||
superadmin_hard_delete_media_asset,
|
||||
transition_recover_from_hidden,
|
||||
transition_to_trash_hidden,
|
||||
transition_to_trash_soft,
|
||||
)
|
||||
from media_storage import get_effective_media_root, path_under_media_root
|
||||
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
|
||||
|
||||
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
|
||||
|
||||
|
||||
class MediaLifecycleBody(BaseModel):
|
||||
action: Literal[
|
||||
"trash_soft",
|
||||
"trash_hidden",
|
||||
"recover",
|
||||
"purge",
|
||||
"reactivate",
|
||||
"superadmin_force_lifecycle",
|
||||
"superadmin_hard_delete",
|
||||
]
|
||||
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _target_lifecycle_rules(self):
|
||||
if self.action == "superadmin_force_lifecycle":
|
||||
if not self.target_lifecycle:
|
||||
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
|
||||
elif self.target_lifecycle is not None:
|
||||
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
|
||||
return self
|
||||
|
||||
|
||||
class MediaAssetPatch(BaseModel):
|
||||
copyright_notice: Optional[str] = Field(None, max_length=8000)
|
||||
original_filename: Optional[str] = Field(None, max_length=300)
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
club_id: Optional[int] = None
|
||||
|
||||
|
||||
class MediaBulkLifecycleBody(BaseModel):
|
||||
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
|
||||
action: Literal[
|
||||
"trash_soft",
|
||||
"trash_hidden",
|
||||
"recover",
|
||||
"purge",
|
||||
"reactivate",
|
||||
"superadmin_force_lifecycle",
|
||||
"superadmin_hard_delete",
|
||||
]
|
||||
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
|
||||
|
||||
@model_validator(mode="after")
|
||||
def _bulk_target(self):
|
||||
if self.action == "superadmin_force_lifecycle" and not self.target_lifecycle:
|
||||
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
|
||||
if self.action != "superadmin_force_lifecycle" and self.target_lifecycle is not None:
|
||||
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
|
||||
return self
|
||||
|
||||
|
||||
class MediaBulkPatchBody(BaseModel):
|
||||
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
|
||||
copyright_notice: Optional[str] = Field(None, max_length=8000)
|
||||
original_filename: Optional[str] = Field(None, max_length=300)
|
||||
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
|
||||
club_id: Optional[int] = None
|
||||
|
||||
|
||||
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
|
||||
|
||||
|
||||
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
|
||||
"""Nach visibility-Wechsel club_id konsistent setzen (official/private → NULL)."""
|
||||
eff = dict(patch_fields)
|
||||
if eff.get("visibility") is not None:
|
||||
v = str(eff["visibility"]).strip().lower()
|
||||
if v in ("official", "private"):
|
||||
eff["club_id"] = None
|
||||
elif v == "club" and "club_id" not in eff:
|
||||
eff["club_id"] = asset.get("club_id")
|
||||
return eff
|
||||
|
||||
|
||||
def _lifecycle_where_sql(lifecycle: str) -> str:
|
||||
lc = (lifecycle or "active").strip().lower()
|
||||
if lc not in _LIFECYCLE_LIST_FILTERS:
|
||||
raise HTTPException(status_code=400, detail="Ungültiger lifecycle-Filter")
|
||||
if lc == "active":
|
||||
return "ma.lifecycle_state = 'active'"
|
||||
if lc == "trash_soft":
|
||||
return "ma.lifecycle_state = 'trash_soft'"
|
||||
if lc == "trash_hidden":
|
||||
return "ma.lifecycle_state = 'trash_hidden'"
|
||||
return "ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')"
|
||||
|
||||
|
||||
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
|
||||
cur.execute(
|
||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
||||
storage_key, mime_type, original_filename
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
return r2d(row) if row else None
|
||||
|
||||
|
||||
def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict) -> None:
|
||||
if not library_content_visible_to_profile(
|
||||
cur,
|
||||
tenant.profile_id,
|
||||
(asset.get("visibility") or "").strip().lower(),
|
||||
asset.get("club_id"),
|
||||
asset.get("uploaded_by_profile_id"),
|
||||
tenant.global_role,
|
||||
):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
|
||||
|
||||
|
||||
def _item_permissions(row: dict, tenant: TenantContext, admin_club_ids: set[int]) -> dict:
|
||||
"""Berechnete UI-/Policy-Flags pro Zeile (ohne zusätzliche DB)."""
|
||||
role_raw = tenant.global_role
|
||||
role = (role_raw or "").strip().lower()
|
||||
pid = int(tenant.profile_id)
|
||||
sup = is_superadmin(role_raw)
|
||||
plat = is_platform_admin(role_raw)
|
||||
vis = (row.get("visibility") or "private").strip().lower()
|
||||
uid = row.get("uploaded_by_profile_id")
|
||||
cid = row.get("club_id")
|
||||
lc = (row.get("lifecycle_state") or "active").strip().lower()
|
||||
is_owner = uid is not None and int(uid) == pid
|
||||
club_mgr = plat or (cid is not None and int(cid) in admin_club_ids)
|
||||
|
||||
edit_metadata = (
|
||||
sup
|
||||
or (vis == "official" and plat)
|
||||
or (vis == "club" and club_mgr)
|
||||
or (vis == "private" and is_owner)
|
||||
)
|
||||
|
||||
trash_soft = lc == "active" and (
|
||||
sup or plat or (vis == "private" and is_owner) or (vis == "club" and club_mgr)
|
||||
)
|
||||
if vis == "official" and not (sup or plat):
|
||||
trash_soft = False
|
||||
|
||||
can_manage_adv = (
|
||||
sup
|
||||
or plat
|
||||
or (vis == "private" and is_owner)
|
||||
or (vis == "club" and club_mgr)
|
||||
)
|
||||
|
||||
trash_hidden = lc in ("active", "trash_soft") and can_manage_adv
|
||||
recover_from_hidden = lc == "trash_hidden" and can_manage_adv
|
||||
reactivate = lc in ("trash_soft", "trash_hidden") and can_manage_adv
|
||||
purge = lc == "trash_hidden" and sup
|
||||
|
||||
return {
|
||||
"edit_metadata": edit_metadata,
|
||||
"change_visibility": edit_metadata,
|
||||
"trash_soft": trash_soft,
|
||||
"trash_hidden": trash_hidden,
|
||||
"recover": recover_from_hidden,
|
||||
"reactivate": reactivate,
|
||||
"purge": purge,
|
||||
"superadmin_lifecycle": sup,
|
||||
"superadmin_hard_delete": sup,
|
||||
}
|
||||
|
||||
|
||||
def _apply_lifecycle_action(
|
||||
cur: Any,
|
||||
conn: Any,
|
||||
asset_id: int,
|
||||
body: MediaLifecycleBody,
|
||||
tenant: TenantContext,
|
||||
) -> dict:
|
||||
asset = fetch_media_asset_row(cur, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
|
||||
action = body.action
|
||||
role_raw = tenant.global_role
|
||||
|
||||
if action == "superadmin_hard_delete":
|
||||
if not is_superadmin(role_raw):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
||||
ok = superadmin_hard_delete_media_asset(cur, conn, asset_id)
|
||||
if not ok:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
return {"ok": True, "hard_deleted": asset_id}
|
||||
|
||||
if action == "superadmin_force_lifecycle":
|
||||
if not is_superadmin(role_raw):
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
||||
tl = body.target_lifecycle or "active"
|
||||
mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN}
|
||||
return superadmin_force_lifecycle_state(cur, conn, asset_id, mp[tl])
|
||||
|
||||
if action == "purge":
|
||||
if not is_superadmin(role_raw):
|
||||
raise HTTPException(status_code=403, detail="Endgültiges Löschen nur als Superadmin")
|
||||
state = (asset.get("lifecycle_state") or "").strip().lower()
|
||||
if state != LC_TRASH_HIDDEN:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Nur ausgeblendete Medien (Stufe 2) dürfen mit dieser Aktion entfernt werden",
|
||||
)
|
||||
if not purge_media_asset(cur, conn, asset_id):
|
||||
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
|
||||
return {"ok": True, "purged": asset_id}
|
||||
|
||||
if action == "trash_soft":
|
||||
assert_can_trash_soft(cur, tenant, asset)
|
||||
return transition_to_trash_soft(cur, conn, asset_id)
|
||||
if action == "trash_hidden":
|
||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
||||
return transition_to_trash_hidden(cur, conn, asset_id)
|
||||
if action == "recover":
|
||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
||||
return transition_recover_from_hidden(cur, conn, asset_id)
|
||||
if action == "reactivate":
|
||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
||||
return reactivate_media_asset_from_trash(cur, conn, asset_id)
|
||||
|
||||
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
|
||||
|
||||
|
||||
@router.get("")
|
||||
def list_media_assets(
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
q: Optional[str] = Query(None, max_length=120),
|
||||
lifecycle: str = Query(
|
||||
"active",
|
||||
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
|
||||
),
|
||||
limit: int = Query(30, ge=1, le=100),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
lc_where = _lifecycle_where_sql(lifecycle)
|
||||
role = tenant.global_role or ""
|
||||
is_adm = is_platform_admin(role)
|
||||
profile_id = tenant.profile_id
|
||||
needle = (q or "").strip()
|
||||
params: list[Any] = [is_adm, profile_id, profile_id]
|
||||
search_sql = ""
|
||||
if needle:
|
||||
like = f"%{needle}%"
|
||||
params.extend([like, like])
|
||||
search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)"
|
||||
params.extend([limit, offset])
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
|
||||
cur.execute(
|
||||
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
|
||||
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
|
||||
ma.copyright_notice, ma.storage_key,
|
||||
pr.name AS uploader_name,
|
||||
pr.email AS uploader_email,
|
||||
cl.name AS club_name
|
||||
FROM media_assets ma
|
||||
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
|
||||
LEFT JOIN clubs cl ON cl.id = ma.club_id
|
||||
WHERE {lc_where}
|
||||
AND (
|
||||
%s
|
||||
OR lower(trim(ma.visibility)) = 'official'
|
||||
OR (
|
||||
lower(trim(ma.visibility)) = 'private'
|
||||
AND ma.uploaded_by_profile_id = %s
|
||||
)
|
||||
OR (
|
||||
lower(trim(ma.visibility)) = 'club'
|
||||
AND EXISTS (
|
||||
SELECT 1 FROM club_members cm
|
||||
WHERE cm.profile_id = %s
|
||||
AND cm.club_id = ma.club_id
|
||||
AND cm.status = 'active'
|
||||
)
|
||||
)
|
||||
)
|
||||
{search_sql}
|
||||
ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
|
||||
LIMIT %s OFFSET %s""",
|
||||
params,
|
||||
)
|
||||
rows = [r2d(r) for r in cur.fetchall()]
|
||||
show_uploader = is_superadmin(role) or is_platform_admin(role) or bool(admin_club_ids)
|
||||
show_club = is_superadmin(role) or is_platform_admin(role)
|
||||
for r in rows:
|
||||
r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
|
||||
if not show_uploader:
|
||||
r["uploader_name"] = None
|
||||
r["uploader_email"] = None
|
||||
if not show_club:
|
||||
r["club_name"] = None
|
||||
viewer = {
|
||||
"show_uploader_meta": show_uploader,
|
||||
"show_club_meta": show_club,
|
||||
"is_superadmin": is_superadmin(role),
|
||||
"is_platform_admin": is_platform_admin(role),
|
||||
}
|
||||
|
||||
return {
|
||||
"items": rows,
|
||||
"limit": limit,
|
||||
"offset": offset,
|
||||
"lifecycle": lifecycle.strip().lower(),
|
||||
"viewer": viewer,
|
||||
}
|
||||
|
||||
|
||||
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
|
||||
def download_media_asset_file(
|
||||
request: Request,
|
||||
asset_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context_flexible),
|
||||
):
|
||||
"""Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken)."""
|
||||
from routers.exercises import _binary_media_response
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
asset = _fetch_asset_file_row(cur, asset_id)
|
||||
if not asset:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
lc = (asset.get("lifecycle_state") or "").strip().lower()
|
||||
if lc == "active":
|
||||
_assert_can_view_archive_asset(cur, tenant, asset)
|
||||
elif lc in ("trash_soft", "trash_hidden"):
|
||||
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
|
||||
else:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
|
||||
|
||||
sk = asset.get("storage_key")
|
||||
if not sk:
|
||||
raise HTTPException(status_code=404, detail="Keine Datei hinterlegt")
|
||||
|
||||
media_root = get_effective_media_root(cur)
|
||||
abs_p = path_under_media_root(media_root, str(sk))
|
||||
if not abs_p or not abs_p.is_file():
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
|
||||
mime = asset.get("mime_type") or "application/octet-stream"
|
||||
fname = asset.get("original_filename") or abs_p.name
|
||||
return _binary_media_response(abs_p, mime, str(fname) if fname else None, request)
|
||||
|
||||
|
||||
@router.post("/{asset_id}/lifecycle")
|
||||
def post_media_asset_lifecycle(
|
||||
asset_id: int,
|
||||
body: MediaLifecycleBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
return _apply_lifecycle_action(cur, conn, asset_id, body, tenant)
|
||||
|
||||
|
||||
@router.post("/bulk-lifecycle")
|
||||
def bulk_media_lifecycle(
|
||||
body: MediaBulkLifecycleBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
inner = MediaLifecycleBody(action=body.action, target_lifecycle=body.target_lifecycle)
|
||||
updated: list[int] = []
|
||||
failed: list[dict] = []
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for aid in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
|
||||
try:
|
||||
_apply_lifecycle_action(cur, conn, aid, inner, tenant)
|
||||
updated.append(aid)
|
||||
except HTTPException as he:
|
||||
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
|
||||
failed.append({"id": aid, "detail": msg})
|
||||
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
|
||||
|
||||
|
||||
@router.post("/bulk-patch")
|
||||
def bulk_media_patch(
|
||||
body: MediaBulkPatchBody,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
|
||||
patch_fields = {k: v for k, v in raw.items() if k != "media_asset_ids" and v is not None}
|
||||
if not patch_fields:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
updated: list[int] = []
|
||||
failed: list[dict] = []
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
for asset_id in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
|
||||
try:
|
||||
cur.execute(
|
||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
||||
copyright_notice, original_filename
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
failed.append({"id": asset_id, "detail": "Medium nicht gefunden"})
|
||||
continue
|
||||
asset = r2d(row)
|
||||
assert_can_edit_media_asset_metadata(cur, tenant, asset)
|
||||
|
||||
eff = _effective_media_patch_fields(patch_fields, asset)
|
||||
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
|
||||
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
|
||||
if "visibility" in patch_fields or "club_id" in patch_fields:
|
||||
assert_valid_governance_visibility(
|
||||
cur,
|
||||
profile_id,
|
||||
role,
|
||||
next_vis,
|
||||
int(next_cid) if next_cid is not None else None,
|
||||
)
|
||||
|
||||
sets: list[str] = []
|
||||
vals: list[Any] = []
|
||||
if "copyright_notice" in patch_fields:
|
||||
sets.append("copyright_notice = %s")
|
||||
vals.append(patch_fields["copyright_notice"])
|
||||
if "original_filename" in patch_fields:
|
||||
sets.append("original_filename = %s")
|
||||
vals.append(patch_fields["original_filename"])
|
||||
if "visibility" in patch_fields or "club_id" in patch_fields:
|
||||
sets.append("visibility = %s")
|
||||
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
|
||||
sets.append("club_id = %s")
|
||||
vals.append(eff.get("club_id"))
|
||||
if not sets:
|
||||
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
|
||||
continue
|
||||
sets.append("updated_at = NOW()")
|
||||
vals.append(asset_id)
|
||||
cur.execute(
|
||||
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
conn.commit()
|
||||
updated.append(asset_id)
|
||||
except HTTPException as he:
|
||||
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
|
||||
failed.append({"id": asset_id, "detail": msg})
|
||||
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
|
||||
|
||||
|
||||
@router.patch("/{asset_id}")
|
||||
def patch_media_asset(
|
||||
asset_id: int,
|
||||
body: MediaAssetPatch,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
profile_id = tenant.profile_id
|
||||
role = tenant.global_role
|
||||
data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
|
||||
if not data:
|
||||
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
|
||||
copyright_notice, original_filename
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
asset = r2d(row)
|
||||
assert_can_edit_media_asset_metadata(cur, tenant, asset)
|
||||
|
||||
eff = _effective_media_patch_fields(data, asset)
|
||||
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
|
||||
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
|
||||
if "visibility" in data or "club_id" in data:
|
||||
assert_valid_governance_visibility(
|
||||
cur,
|
||||
profile_id,
|
||||
role,
|
||||
next_vis,
|
||||
int(next_cid) if next_cid is not None else None,
|
||||
)
|
||||
|
||||
sets: list[str] = []
|
||||
vals: list[Any] = []
|
||||
if "copyright_notice" in data:
|
||||
sets.append("copyright_notice = %s")
|
||||
vals.append(data["copyright_notice"])
|
||||
if "original_filename" in data:
|
||||
sets.append("original_filename = %s")
|
||||
vals.append(data["original_filename"])
|
||||
if "visibility" in data or "club_id" in data:
|
||||
sets.append("visibility = %s")
|
||||
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
|
||||
sets.append("club_id = %s")
|
||||
vals.append(eff.get("club_id"))
|
||||
if sets:
|
||||
sets.append("updated_at = NOW()")
|
||||
vals.append(asset_id)
|
||||
cur.execute(
|
||||
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
|
||||
tuple(vals),
|
||||
)
|
||||
conn.commit()
|
||||
cur.execute(
|
||||
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
|
||||
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice
|
||||
FROM media_assets WHERE id = %s""",
|
||||
(asset_id,),
|
||||
)
|
||||
out = r2d(cur.fetchone())
|
||||
return out
|
||||
97
backend/routers/platform_media_storage.py
Normal file
97
backend/routers/platform_media_storage.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Superadmin: Speicherpfad-Konfiguration (§7.1 MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from media_storage import _default_media_root, get_effective_media_root, normalize_local_relative_root
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin", "media-storage"])
|
||||
|
||||
|
||||
class PlatformMediaStorageUpdate(BaseModel):
|
||||
local_relative_root: str = Field(
|
||||
"",
|
||||
description="Relativer Unterordner unter MEDIA_ROOT (z. B. nas/videos). Leer = nur MEDIA_ROOT.",
|
||||
max_length=512,
|
||||
)
|
||||
|
||||
|
||||
class PlatformMediaStorageOut(BaseModel):
|
||||
storage_backend: str
|
||||
local_relative_root: str
|
||||
media_root_env: str
|
||||
effective_media_root: str
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> None:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
||||
|
||||
|
||||
@router.get("/platform-media-storage", response_model=PlatformMediaStorageOut)
|
||||
def get_platform_media_storage(session: dict = Depends(require_auth)):
|
||||
"""Lesen: Plattform-Admin (admin/superadmin) – Hilfe für Betrieb."""
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_platform_admin(role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT storage_backend, local_relative_root
|
||||
FROM platform_media_storage WHERE id = 1""",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
backend, rel = "local", ""
|
||||
else:
|
||||
d = r2d(row)
|
||||
backend = d.get("storage_backend") or "local"
|
||||
rel = str(d.get("local_relative_root") or "")
|
||||
eff = get_effective_media_root(cur)
|
||||
return PlatformMediaStorageOut(
|
||||
storage_backend=backend,
|
||||
local_relative_root=rel,
|
||||
media_root_env=str(_default_media_root().resolve()),
|
||||
effective_media_root=str(eff),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/platform-media-storage", response_model=PlatformMediaStorageOut)
|
||||
def put_platform_media_storage(
|
||||
body: PlatformMediaStorageUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Schreiben: nur superadmin."""
|
||||
_require_superadmin(session)
|
||||
profile_id = session["profile_id"]
|
||||
try:
|
||||
rel_norm = normalize_local_relative_root(body.local_relative_root)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""UPDATE platform_media_storage
|
||||
SET local_relative_root = %s, updated_at = NOW(), updated_by_profile_id = %s
|
||||
WHERE id = 1
|
||||
RETURNING storage_backend, local_relative_root""",
|
||||
(rel_norm, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="platform_media_storage fehlt")
|
||||
d = r2d(row)
|
||||
eff = get_effective_media_root(cur)
|
||||
return PlatformMediaStorageOut(
|
||||
storage_backend=str(d.get("storage_backend") or "local"),
|
||||
local_relative_root=str(d.get("local_relative_root") or ""),
|
||||
media_root_env=str(_default_media_root().resolve()),
|
||||
effective_media_root=str(eff),
|
||||
)
|
||||
|
|
@ -22,19 +22,6 @@ router = APIRouter(prefix="/api", tags=["profiles"])
|
|||
_ALLOWED_PORTAL_ROLES = frozenset({"user", "trainer", "admin", "superadmin"})
|
||||
|
||||
|
||||
# ── Helper ────────────────────────────────────────────────────────────────────
|
||||
def get_pid(x_profile_id: Optional[str] = Header(default=None)) -> str:
|
||||
"""Get profile_id - from header for legacy endpoints."""
|
||||
if x_profile_id:
|
||||
return x_profile_id
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT id FROM profiles ORDER BY created LIMIT 1")
|
||||
row = cur.fetchone()
|
||||
if row: return row['id']
|
||||
raise HTTPException(400, "Kein Profil gefunden")
|
||||
|
||||
|
||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||
@router.get("/profiles/me")
|
||||
def get_current_profile(
|
||||
|
|
@ -310,26 +297,45 @@ def delete_profile(pid: str, session=Depends(require_auth)):
|
|||
cur.execute("SELECT COUNT(*) as count FROM profiles")
|
||||
count = cur.fetchone()['count']
|
||||
if count <= 1: raise HTTPException(400, "Letztes Profil kann nicht gelöscht werden")
|
||||
for table in ['weight_log','circumference_log','caliper_log','nutrition_log','activity_log','ai_insights']:
|
||||
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
||||
# Mitai-Überbleibsel: nur löschen, wenn die Tabelle im Schema existiert (Shinkan-DB ohne diese Tabellen).
|
||||
_optional_mitai_tables = (
|
||||
"weight_log",
|
||||
"circumference_log",
|
||||
"caliper_log",
|
||||
"nutrition_log",
|
||||
"activity_log",
|
||||
"ai_insights",
|
||||
)
|
||||
for table in _optional_mitai_tables:
|
||||
cur.execute(
|
||||
"""
|
||||
SELECT EXISTS (
|
||||
SELECT 1 FROM information_schema.tables
|
||||
WHERE table_schema = 'public' AND table_name = %s
|
||||
) AS t_exists
|
||||
""",
|
||||
(table,),
|
||||
)
|
||||
ex = cur.fetchone()
|
||||
if ex and next(iter(ex.values())):
|
||||
cur.execute(f"DELETE FROM {table} WHERE profile_id=%s", (pid,))
|
||||
cur.execute("DELETE FROM profiles WHERE id=%s", (pid,))
|
||||
return {"ok": True}
|
||||
|
||||
|
||||
# ── Current User Profile ──────────────────────────────────────────────────────
|
||||
@router.get("/profile")
|
||||
def get_active_profile(x_profile_id: Optional[str] = Header(default=None), session: dict = Depends(require_auth)):
|
||||
"""Legacy endpoint – returns active profile."""
|
||||
pid = get_pid(x_profile_id)
|
||||
def get_active_profile(session: dict = Depends(require_auth)):
|
||||
"""Legacy-Alias für das eingeloggte Profil — immer Session, kein X-Profile-Id (SECURITY: kein IDOR)."""
|
||||
pid = str(session["profile_id"])
|
||||
return profile_document(pid)
|
||||
|
||||
|
||||
@router.put("/profile")
|
||||
def update_active_profile(
|
||||
p: ProfileUpdate,
|
||||
x_profile_id: Optional[str] = Header(default=None),
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""Update current user's profile."""
|
||||
pid = get_pid(x_profile_id)
|
||||
"""Profil des eingeloggten Nutzers aktualisieren — dieselbe Quelle wie GET /profile."""
|
||||
pid = str(tenant.profile_id)
|
||||
return _run_profile_update(pid, p, tenant)
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
{
|
||||
"auth.py",
|
||||
"admin_users.py",
|
||||
"platform_media_storage.py",
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
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())
|
||||
32
backend/scripts/security_release_checks.py
Normal file
32
backend/scripts/security_release_checks.py
Normal file
|
|
@ -0,0 +1,32 @@
|
|||
#!/usr/bin/env python3
|
||||
"""
|
||||
CI-Sicherheitschecks: schlanke pytest-Sammlung ohne Integrations-DB.
|
||||
|
||||
Repo-Root ist egal — arbeitet relativ zu diesem Script (backend/).
|
||||
|
||||
Usage (aus backend/):
|
||||
python scripts/security_release_checks.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def main() -> int:
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
test_file = root / "tests" / "test_security_release.py"
|
||||
cmd = [
|
||||
sys.executable,
|
||||
"-m",
|
||||
"pytest",
|
||||
str(test_file),
|
||||
"-v",
|
||||
"--tb=short",
|
||||
]
|
||||
return subprocess.run(cmd, cwd=str(root)).returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
|
|
@ -11,7 +11,7 @@ from typing import Any, Dict, List, Optional
|
|||
|
||||
from fastapi import Depends, Header, HTTPException
|
||||
|
||||
from auth import require_auth
|
||||
from auth import require_auth, require_auth_flexible
|
||||
from club_tenancy import is_platform_admin, memberships_with_roles
|
||||
from db import get_db, get_cursor
|
||||
|
||||
|
|
@ -183,6 +183,34 @@ def get_tenant_context(
|
|||
)
|
||||
|
||||
|
||||
def get_tenant_context_flexible(
|
||||
session: dict = Depends(require_auth_flexible),
|
||||
x_active_club_id: Optional[str] = Header(default=None, alias="X-Active-Club-Id"),
|
||||
) -> TenantContext:
|
||||
"""
|
||||
Wie get_tenant_context, aber Auth per Header oder Query ?ssetoken (für <img>/<video> ohne Custom-Header).
|
||||
"""
|
||||
pid = int(session["profile_id"])
|
||||
role = session.get("role") or ""
|
||||
stored: Optional[int] = None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute("SELECT active_club_id FROM profiles WHERE id = %s", (pid,))
|
||||
row = cur.fetchone()
|
||||
if row is not None:
|
||||
ac = row.get("active_club_id")
|
||||
if ac is not None:
|
||||
stored = int(ac)
|
||||
return resolve_tenant_context(
|
||||
cur,
|
||||
profile_id=pid,
|
||||
global_role=role,
|
||||
header_raw=x_active_club_id,
|
||||
memberships=None,
|
||||
stored_active_club_id=stored,
|
||||
)
|
||||
|
||||
|
||||
def tenant_context_from_session_only(
|
||||
cur,
|
||||
session: dict,
|
||||
|
|
|
|||
75
backend/tests/test_exercise_media_download.py
Normal file
75
backend/tests/test_exercise_media_download.py
Normal file
|
|
@ -0,0 +1,75 @@
|
|||
"""
|
||||
Geschützte Übungs-Mediendatei: Auth + Governance (kein anonymes /media/).
|
||||
"""
|
||||
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_flexible
|
||||
from main import app
|
||||
from tenant_context import TenantContext, get_tenant_context_flexible
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_overrides():
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth_flexible, None)
|
||||
app.dependency_overrides.pop(get_tenant_context_flexible, None)
|
||||
|
||||
|
||||
def test_exercise_media_file_unauthenticated_401(client: TestClient) -> None:
|
||||
r = client.get("/api/exercises/1/media/2/file")
|
||||
assert r.status_code == 401
|
||||
|
||||
|
||||
def test_exercise_media_file_forbidden_when_not_visible(client: TestClient) -> None:
|
||||
app.dependency_overrides[get_tenant_context_flexible] = lambda: TenantContext(
|
||||
profile_id=22,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
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 = {
|
||||
"id": 3,
|
||||
"visibility": "private",
|
||||
"club_id": None,
|
||||
"created_by": 99,
|
||||
}
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
), patch("routers.exercises.library_content_visible_to_profile", return_value=False):
|
||||
r = client.get("/api/exercises/3/media/2/file", headers={"X-Auth-Token": "t"})
|
||||
assert r.status_code == 403
|
||||
|
||||
|
||||
def test_get_maturity_model_requires_admin(client: TestClient) -> None:
|
||||
from auth import require_auth
|
||||
|
||||
def _user() -> dict:
|
||||
return {"profile_id": 1, "role": "trainer"}
|
||||
|
||||
app.dependency_overrides[require_auth] = _user
|
||||
try:
|
||||
r = client.get("/api/maturity-models/1", headers={"X-Auth-Token": "x"})
|
||||
assert r.status_code == 403
|
||||
finally:
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
438
backend/tests/test_media_assets_archive.py
Normal file
438
backend/tests/test_media_assets_archive.py
Normal file
|
|
@ -0,0 +1,438 @@
|
|||
"""
|
||||
Medienarchiv: GET /api/media-assets und POST /api/exercises/{id}/media/from-asset (gemockte DB).
|
||||
"""
|
||||
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
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_overrides() -> None:
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
app.dependency_overrides.pop(get_tenant_context, None)
|
||||
|
||||
|
||||
def _mock_db(mock_cur: MagicMock) -> MagicMock:
|
||||
mock_conn = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
return mock_cm
|
||||
|
||||
|
||||
def test_list_media_assets_ok_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchall.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"mime_type": "image/png",
|
||||
"byte_size": 100,
|
||||
"original_filename": "a.png",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 2,
|
||||
"lifecycle_state": "active",
|
||||
"created_at": None,
|
||||
"sha256": "a" * 64,
|
||||
"copyright_notice": None,
|
||||
}
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
|
||||
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["limit"] == 30
|
||||
assert len(body["items"]) == 1
|
||||
assert body["items"][0]["original_filename"] == "a.png"
|
||||
assert "viewer" in body
|
||||
|
||||
|
||||
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"c": 0},
|
||||
{
|
||||
"id": 5,
|
||||
"mime_type": "image/jpeg",
|
||||
"byte_size": 10,
|
||||
"original_filename": "x.jpg",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"storage_key": "exercises/x.jpg",
|
||||
},
|
||||
{"id": 1},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/exercises/3/media/from-asset",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"media_asset_id": 5,
|
||||
"title": "",
|
||||
"description": "",
|
||||
"context": "ablauf",
|
||||
"is_primary": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "bereits" in (r.json().get("detail") or "").lower()
|
||||
|
||||
|
||||
def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
inserted = {
|
||||
"id": 99,
|
||||
"exercise_id": 3,
|
||||
"media_type": "image",
|
||||
"file_path": "/media/exercises/h.jpg",
|
||||
"file_size": 10,
|
||||
"mime_type": "image/jpeg",
|
||||
"original_filename": "h.jpg",
|
||||
"embed_url": None,
|
||||
"embed_platform": None,
|
||||
"title": "h.jpg",
|
||||
"description": None,
|
||||
"sort_order": 1,
|
||||
"is_primary": False,
|
||||
"context": "ablauf",
|
||||
"created_at": None,
|
||||
"media_asset_id": 5,
|
||||
}
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"c": 0},
|
||||
{
|
||||
"id": 5,
|
||||
"mime_type": "image/jpeg",
|
||||
"byte_size": 10,
|
||||
"original_filename": "h.jpg",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"storage_key": "exercises/h.jpg",
|
||||
},
|
||||
None,
|
||||
inserted,
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/exercises/3/media/from-asset",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"media_asset_id": 5,
|
||||
"context": "detail",
|
||||
"is_primary": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["id"] == 99
|
||||
assert body["media_asset_id"] == 5
|
||||
assert body["asset_lifecycle_state"] == "active"
|
||||
|
||||
|
||||
def test_delete_exercise_media_returns_orphan_when_last_ref(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": 88},
|
||||
{"c": 1},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["orphan_media_asset_id"] == 88
|
||||
|
||||
|
||||
def test_delete_exercise_media_no_orphan_when_shared(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": 88},
|
||||
{"c": 2},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["orphan_media_asset_id"] is None
|
||||
|
||||
|
||||
def test_delete_exercise_media_embed_row_no_orphan(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": None},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/21", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "orphan_media_asset_id": None}
|
||||
|
||||
|
||||
def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{
|
||||
"id": 5,
|
||||
"visibility": "private",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 1,
|
||||
"lifecycle_state": "trash_soft",
|
||||
"storage_key": "exercises/a.mp4",
|
||||
"storage_backend": "local",
|
||||
"trash_soft_at": None,
|
||||
"trash_hidden_at": None,
|
||||
"purge_after_at": None,
|
||||
},
|
||||
{"id": 5, "lifecycle_state": "active"},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/media-assets/5/lifecycle",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={"action": "reactivate"},
|
||||
)
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["lifecycle_state"] == "active"
|
||||
|
||||
|
||||
def test_media_asset_lifecycle_purge_forbidden_for_non_superadmin(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="admin",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
fake_row = {
|
||||
"id": 99,
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 2,
|
||||
"lifecycle_state": "trash_hidden",
|
||||
"storage_key": "x",
|
||||
"storage_backend": "local",
|
||||
"trash_soft_at": None,
|
||||
"trash_hidden_at": None,
|
||||
"purge_after_at": None,
|
||||
}
|
||||
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
), patch("routers.media_assets.fetch_media_asset_row", return_value=fake_row):
|
||||
r = client.post(
|
||||
"/api/media-assets/99/lifecycle",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={"action": "purge"},
|
||||
)
|
||||
|
||||
assert r.status_code == 403
|
||||
assert "Superadmin" in (r.json().get("detail") or "")
|
||||
|
||||
|
||||
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[],
|
||||
)
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchall.return_value = []
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
|
||||
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
|
||||
assert r.status_code == 200
|
||||
assert r.json()["lifecycle"] == "trash_soft"
|
||||
list_sql_calls = [c[0][0] for c in mock_cur.execute.call_args_list if c[0] and "FROM media_assets ma" in str(c[0][0])]
|
||||
assert list_sql_calls and "trash_soft" in list_sql_calls[0]
|
||||
|
||||
|
||||
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
r = client.get("/api/media-assets?lifecycle=invalid", headers={"X-Auth-Token": "t"})
|
||||
assert r.status_code == 400
|
||||
|
||||
|
||||
def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{
|
||||
"id": 9,
|
||||
"visibility": "private",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 10,
|
||||
"lifecycle_state": "active",
|
||||
"copyright_notice": "",
|
||||
"original_filename": "x.png",
|
||||
},
|
||||
{
|
||||
"id": 9,
|
||||
"mime_type": "image/png",
|
||||
"byte_size": 10,
|
||||
"original_filename": "x.png",
|
||||
"visibility": "private",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 10,
|
||||
"lifecycle_state": "active",
|
||||
"created_at": None,
|
||||
"sha256": "b" * 64,
|
||||
"copyright_notice": "© HoldCo",
|
||||
},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
), patch("media_lifecycle.assert_can_manage_media_asset_lifecycle", lambda *a, **k: None):
|
||||
r = client.patch(
|
||||
"/api/media-assets/9",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={"copyright_notice": "© HoldCo"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json()["copyright_notice"] == "© HoldCo"
|
||||
|
||||
107
backend/tests/test_official_exercise_media_rules.py
Normal file
107
backend/tests/test_official_exercise_media_rules.py
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
"""§4.2: apply_official_exercise_media_rules — Lifecycle, Sichtbarkeit, Copyright."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from fastapi import HTTPException
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from routers.exercises import apply_official_exercise_media_rules
|
||||
|
||||
|
||||
def _row(
|
||||
aid: int,
|
||||
*,
|
||||
vis: str = "private",
|
||||
lifecycle: str = "active",
|
||||
copyright_notice: str | None = "",
|
||||
name: str = "f.bin",
|
||||
) -> dict:
|
||||
return {
|
||||
"id": aid,
|
||||
"visibility": vis,
|
||||
"club_id": 1,
|
||||
"lifecycle_state": lifecycle,
|
||||
"copyright_notice": copyright_notice,
|
||||
"original_filename": name,
|
||||
}
|
||||
|
||||
|
||||
def test_non_official_visibility_noop() -> None:
|
||||
cur = MagicMock()
|
||||
apply_official_exercise_media_rules(
|
||||
cur,
|
||||
1,
|
||||
"private",
|
||||
promote_attached_media=False,
|
||||
default_official_media_copyright=None,
|
||||
)
|
||||
cur.execute.assert_not_called()
|
||||
|
||||
|
||||
def test_lifecycle_not_active_422() -> None:
|
||||
cur = MagicMock()
|
||||
cur.fetchall.return_value = [_row(1, lifecycle="trash_soft", copyright_notice="abc")]
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
apply_official_exercise_media_rules(
|
||||
cur,
|
||||
1,
|
||||
"official",
|
||||
promote_attached_media=False,
|
||||
default_official_media_copyright=None,
|
||||
)
|
||||
assert ei.value.status_code == 422
|
||||
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_LIFECYCLE"
|
||||
|
||||
|
||||
def test_visibility_promotion_confirm_422() -> None:
|
||||
cur = MagicMock()
|
||||
cur.fetchall.return_value = [_row(1, vis="club", copyright_notice="halten")]
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
apply_official_exercise_media_rules(
|
||||
cur,
|
||||
1,
|
||||
"official",
|
||||
promote_attached_media=False,
|
||||
default_official_media_copyright=None,
|
||||
)
|
||||
assert ei.value.status_code == 422
|
||||
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED"
|
||||
assert ei.value.detail["assets_need_visibility_promotion"]
|
||||
|
||||
|
||||
def test_copyright_required_422() -> None:
|
||||
cur = MagicMock()
|
||||
cur.fetchall.return_value = [_row(1, vis="official", copyright_notice="")]
|
||||
with pytest.raises(HTTPException) as ei:
|
||||
apply_official_exercise_media_rules(
|
||||
cur,
|
||||
1,
|
||||
"official",
|
||||
promote_attached_media=True,
|
||||
default_official_media_copyright=None,
|
||||
)
|
||||
assert ei.value.status_code == 422
|
||||
assert ei.value.detail["code"] == "OFFICIAL_MEDIA_CONFIRM_REQUIRED"
|
||||
assert ei.value.detail["assets_missing_copyright"]
|
||||
|
||||
|
||||
def test_promote_and_fill_copyright_updates() -> None:
|
||||
cur = MagicMock()
|
||||
cur.fetchall.return_value = [_row(1, vis="private", copyright_notice=" ")]
|
||||
apply_official_exercise_media_rules(
|
||||
cur,
|
||||
42,
|
||||
"official",
|
||||
promote_attached_media=True,
|
||||
default_official_media_copyright="© Test Holding",
|
||||
)
|
||||
assert cur.execute.call_count == 3
|
||||
sql1 = cur.execute.call_args_list[1][0][0]
|
||||
sql2 = cur.execute.call_args_list[2][0][0]
|
||||
assert "visibility = 'official'" in sql1
|
||||
assert "copyright_notice" in sql2
|
||||
|
||||
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"
|
||||
126
backend/tests/test_security_release.py
Normal file
126
backend/tests/test_security_release.py
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
"""
|
||||
Sicherheits-Regression für Release-Konfiguration (OpenAPI, Health-Ready, Legacy-Routen).
|
||||
|
||||
Läuft ohne PostgreSQL, wo möglich (TestClient + Subprocess mit frischem Import).
|
||||
CI: scripts/security_release_checks.py
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
os.environ.setdefault("SKIP_DB_MIGRATE", "1")
|
||||
|
||||
from auth import require_auth
|
||||
from main import app
|
||||
|
||||
BACKEND_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_auth_override():
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
|
||||
|
||||
def test_legacy_get_profile_uses_session_only(client: TestClient) -> None:
|
||||
"""SEC-01: /api/profile darf nur das Session-Profil liefern."""
|
||||
|
||||
def auth_user() -> dict:
|
||||
return {"profile_id": 901, "role": "trainer"}
|
||||
|
||||
app.dependency_overrides[require_auth] = auth_user
|
||||
with patch("routers.profiles.profile_document") as pd_mock:
|
||||
pd_mock.return_value = {"id": 901, "name": "T"}
|
||||
r = client.get("/api/profile", headers={"X-Auth-Token": "dummy"})
|
||||
assert r.status_code == 200
|
||||
pd_mock.assert_called_once_with("901")
|
||||
|
||||
|
||||
def _run_fresh_import_int(code: str, env: dict) -> subprocess.CompletedProcess:
|
||||
merged = {**os.environ, **env, "SKIP_DB_MIGRATE": "1"}
|
||||
return subprocess.run(
|
||||
[sys.executable, "-c", code],
|
||||
cwd=str(BACKEND_ROOT),
|
||||
env=merged,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"extra_env, expect_openapi",
|
||||
[
|
||||
({"ENVIRONMENT": "development"}, True),
|
||||
({"ENVIRONMENT": "production"}, False),
|
||||
({"ENVIRONMENT": "production", "PUBLIC_OPENAPI": "true"}, True),
|
||||
],
|
||||
)
|
||||
def test_openapi_urls_match_environment(extra_env: dict, expect_openapi: bool) -> None:
|
||||
snippet = f"""
|
||||
from main import app
|
||||
assert (app.openapi_url is not None) == {expect_openapi}
|
||||
assert (app.docs_url is not None) == {expect_openapi}
|
||||
"""
|
||||
proc = _run_fresh_import_int(snippet, extra_env)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
||||
|
||||
def test_health_ready_minimal_body_in_production() -> None:
|
||||
snippet = """
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/health/ready")
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
assert "tables" not in j
|
||||
assert "detail" not in j
|
||||
assert "schema_migrations_count" not in j
|
||||
assert "status" in j and "database" in j and "schema_complete" in j
|
||||
"""
|
||||
proc = _run_fresh_import_int(
|
||||
snippet,
|
||||
{"ENVIRONMENT": "production"},
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
||||
|
||||
def test_health_ready_full_detail_when_flag_in_production() -> None:
|
||||
snippet = """
|
||||
from fastapi.testclient import TestClient
|
||||
from main import app
|
||||
c = TestClient(app)
|
||||
r = c.get("/api/health/ready")
|
||||
r.raise_for_status()
|
||||
j = r.json()
|
||||
assert "tables" in j
|
||||
assert "schema_migrations_count" in j
|
||||
"""
|
||||
proc = _run_fresh_import_int(
|
||||
snippet,
|
||||
{"ENVIRONMENT": "production", "HEALTH_READY_PUBLIC_DETAIL": "1"},
|
||||
)
|
||||
assert proc.returncode == 0, proc.stderr + proc.stdout
|
||||
|
||||
|
||||
def test_api_attachments_x_content_type_options_nosniff(client: TestClient) -> None:
|
||||
"""Globales Middleware: keine MIME-Sniffing-Heuristik für API/Health."""
|
||||
r = client.get("/health")
|
||||
assert r.status_code == 200
|
||||
assert r.headers.get("x-content-type-options") == "nosniff"
|
||||
r2 = client.get("/api/version")
|
||||
assert r2.status_code == 200
|
||||
assert r2.headers.get("x-content-type-options") == "nosniff"
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.40"
|
||||
BUILD_DATE = "2026-05-06"
|
||||
DB_SCHEMA_VERSION = "20260506043"
|
||||
APP_VERSION = "0.8.49"
|
||||
BUILD_DATE = "2026-05-07"
|
||||
DB_SCHEMA_VERSION = "20260507045"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
|
||||
|
|
@ -12,10 +12,12 @@ MODULE_VERSIONS = {
|
|||
"club_memberships": "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
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"media_assets": "1.4.0", # Manager: RBAC trash_soft Trainer nur privat; purge nur Superadmin; Superadmin force/hard-delete; Liste + permissions + JOINs; bulk lifecycle/patch
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
|
||||
"exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata)
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -27,6 +29,79 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.49",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Medienbibliothek UI: Kacheln/Liste, Modal Bearbeiten, Video-First-Frame-Thumbs, Mobile/Safe-Area, Bulk; API: permissions pro Zeile, Uploader/Verein für Admin, PATCH Sichtbarkeit+Bezeichner, trash_soft nur Trainer-Eigenes-Privat / Vereinsorga / Plattform; purge nur Superadmin; superadmin_force_lifecycle + hard_delete; bulk-lifecycle, bulk-patch",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.48",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Medienbibliothek: GET /api/media-assets mit lifecycle (active|trash_soft|trash_hidden|all), copyright_notice in Liste; PATCH /api/media-assets/{id} (Copyright); GET …/file für Papierkorb wenn Lifecycle-Recht; Frontend /media + Admin-Nav + Link Übungsformular",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.47",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Übung „offiziell“ (§4.2): angehängte Datei-Assets müssen aktiv sein; Sichtbarkeit/Copyright per Bestätigung anheben; PUT /api/exercises/{id} + PATCH bulk-metadata: Felder promote_attached_media_for_official, default_official_media_copyright; Frontend Bestätigungsdialog",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.46",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Übung Medien-Upload: bei 409-Konflikt (Papierkorb-Dedupe) logger.warning mit exercise_id, profile_id, media_asset_id, lifecycle, visibility, club_id, sha256_prefix, Dateinamen",
|
||||
"ExerciseFormPage: erklärende Medien-Hinweistexte in GUI entfernt (Kurzbeschreibung + Archiv-Dialog)",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.45",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Upload Übungsmedien: gleicher Inhalt (SHA-256) wie Papierkorb-Asset → 409 MEDIA_ASSET_IN_TRASH statt DB-Fehler; Lifecycle action reactivate (trash_soft/hidden → active)",
|
||||
"Frontend: Dialog Reaktivieren + Verknüpfen; uploadExerciseMedia wertet strukturiertes 409 aus",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.44",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"DELETE /api/exercises/{id}/media/{mid}: entfernt nur exercise_media; keine Datei-/media_assets-Löschung; Response orphan_media_asset_id wenn letzte Referenz",
|
||||
"Übung bearbeiten: Video-Kachel (Erstframe), Dateiname; Papierkorb-Schalter entfernt; „Aus Übung entfernen“ + optional Papierkorb bei Waise",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.43",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"Medienarchiv: GET /api/media-assets (Suche, nur aktive Assets, Bibliotheks-Sichtbarkeit); GET /api/media-assets/{id}/file (Thumbnails/Vorschau, ssetoken)",
|
||||
"Übungen: POST /api/exercises/{id}/media/from-asset — bestehendes Archiv-Medium verknüpfen ohne Upload-Duplikat",
|
||||
"Frontend Übung bearbeiten: „Aus Archiv verknüpfen…“, Medienvorschau-Modal, Kachel-Thumbnails in der Medienliste",
|
||||
],
|
||||
},
|
||||
{
|
||||
"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",
|
||||
"changes": [
|
||||
"DB 045: media_assets, exercise_media.media_asset_id, platform_media_storage; Migration bestehender Medien; Upload-Dedupe pro sha256+visibility+club_id",
|
||||
"Effektives Medien-Verzeichnis: MEDIA_ROOT + Superadmin local_relative_root (GET/PUT /api/admin/platform-media-storage)",
|
||||
"Neue Uploads: storage_key exercises/{sha256}{ext}; Download/Delete nutzen media_assets",
|
||||
"api.js: getPlatformMediaStorage, putPlatformMediaStorage",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.40",
|
||||
"date": "2026-05-06",
|
||||
|
|
|
|||
|
|
@ -8,8 +8,9 @@ services:
|
|||
POSTGRES_PASSWORD: ${DB_PASSWORD}
|
||||
volumes:
|
||||
- shinkan-db-data:/var/lib/postgresql/data
|
||||
# Nur localhost: DB nicht im LAN exponieren (Beta/Prod). Entferne 127.0.0.1: nur wenn du bewusst remote willst.
|
||||
ports:
|
||||
- "5434:5432"
|
||||
- "127.0.0.1:5434:5432"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- shinkan-network
|
||||
|
|
|
|||
|
|
@ -4,6 +4,11 @@ server {
|
|||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
add_header X-Content-Type-Options "nosniff" always;
|
||||
add_header X-Frame-Options "SAMEORIGIN" always;
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
|
||||
add_header Permissions-Policy "camera=(), microphone=(), geolocation=()" always;
|
||||
|
||||
# Docker-Embedded DNS: Hostname »backend« bei Container-Neustarts neu auflösen
|
||||
# — verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
|
||||
resolver 127.0.0.11 valid=10s ipv6=off;
|
||||
|
|
@ -26,6 +31,8 @@ server {
|
|||
}
|
||||
|
||||
location ^~ /media/ {
|
||||
# Auslieferung Übungsdateien erfolgt geschützt über /api/exercises/.../media/.../file (?ssetoken).
|
||||
# Optional: Backend mit ALLOW_PUBLIC_MEDIA_STATIC=1 → wieder /media/ ohne Auth.
|
||||
set $docker_backend_svc backend;
|
||||
proxy_pass http://$docker_backend_svc:8000$request_uri;
|
||||
proxy_http_version 1.1;
|
||||
|
|
@ -42,8 +49,10 @@ server {
|
|||
proxy_set_header Host $host;
|
||||
}
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
# Document-CSP für SPA/PWA — React nutzt häufig inline-styles; Mediendateien & API sind same-origin (Proxy).
|
||||
# Bei separater API-Origin: connect-src hier erweitern oder nginx-Envsubst nutzen.
|
||||
add_header Content-Security-Policy "default-src 'self'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; object-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob: https:; font-src 'self' data:; connect-src 'self'; media-src 'self' blob: data:; worker-src 'self' blob:; manifest-src 'self';" always;
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import AdminMaturityModelsPage from './pages/AdminMaturityModelsPage'
|
|||
import TrainerContextsPage from './pages/TrainerContextsPage'
|
||||
import MediaWikiImportPage from './pages/MediaWikiImportPage'
|
||||
import AdminUsersPage from './pages/AdminUsersPage'
|
||||
import MediaLibraryPage from './pages/MediaLibraryPage'
|
||||
import ActiveClubSwitcher from './components/ActiveClubSwitcher'
|
||||
import './app.css'
|
||||
|
||||
|
|
@ -158,6 +159,7 @@ function AppRoutes() {
|
|||
<Route path="profile" element={<Navigate to="/settings" replace />} />
|
||||
<Route path="settings" element={<AccountSettingsPage />} />
|
||||
<Route path="settings/system" element={<SettingsSystemInfoPage />} />
|
||||
<Route path="media" element={<MediaLibraryPage />} />
|
||||
<Route path="exercises">
|
||||
<Route index element={<ExercisesListPage />} />
|
||||
<Route path="new" element={<ExerciseFormPage />} />
|
||||
|
|
|
|||
|
|
@ -5454,3 +5454,516 @@ a.analysis-split__nav-item {
|
|||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: 4px;
|
||||
}
|
||||
|
||||
/* ——— Medienbibliothek (/media) ——— */
|
||||
.media-library {
|
||||
padding: max(12px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px))
|
||||
max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px));
|
||||
}
|
||||
.media-library__container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.media-library__hero {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
.media-library__hero-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.media-library__title {
|
||||
font-size: clamp(1.35rem, 2.5vw, 1.65rem);
|
||||
font-weight: 700;
|
||||
letter-spacing: -0.02em;
|
||||
margin: 0;
|
||||
}
|
||||
.media-library__hero-links {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.media-library__hero-links a {
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.media-library__intro {
|
||||
margin: 0.5rem 0 0;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text2);
|
||||
line-height: 1.5;
|
||||
max-width: 46rem;
|
||||
}
|
||||
.media-library__toolbar {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 12px 14px;
|
||||
margin-bottom: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.media-library__toolbar-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
align-items: center;
|
||||
}
|
||||
.media-library__search {
|
||||
flex: 1 1 200px;
|
||||
min-width: 0;
|
||||
}
|
||||
.media-library__select {
|
||||
min-width: 160px;
|
||||
}
|
||||
.media-library__view-toggle {
|
||||
display: inline-flex;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-library__toolbar-meta {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.media-library__check-all {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-library__refresh {
|
||||
margin-left: auto;
|
||||
}
|
||||
.media-library__icon-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 44px;
|
||||
min-height: 44px;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: var(--surface);
|
||||
color: var(--text1);
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-library__icon-btn--on {
|
||||
background: var(--accent-light);
|
||||
border-color: var(--accent);
|
||||
color: var(--accent-dark);
|
||||
}
|
||||
.media-library__err {
|
||||
color: var(--danger);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
.media-library__empty,
|
||||
.media-library__hint {
|
||||
color: var(--text2);
|
||||
font-size: 0.9rem;
|
||||
}
|
||||
.media-library__spinner {
|
||||
margin: 2rem auto;
|
||||
}
|
||||
.media-library__grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
|
||||
gap: 14px;
|
||||
}
|
||||
@media (min-width: 768px) {
|
||||
.media-library__grid {
|
||||
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
}
|
||||
.media-library__card {
|
||||
position: relative;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-width: 0;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
.media-library__card-check {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
left: 8px;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.media-library__card-check input {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-library__card-menu {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
z-index: 3;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
background: rgba(255, 255, 255, 0.92);
|
||||
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
|
||||
cursor: pointer;
|
||||
color: var(--text1);
|
||||
}
|
||||
.media-library__card-thumb-hit {
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
color: inherit;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.media-library__card-thumb-hit:active {
|
||||
opacity: 0.96;
|
||||
}
|
||||
.media-library__card-thumb-wrap {
|
||||
aspect-ratio: 1;
|
||||
background: var(--surface2);
|
||||
position: relative;
|
||||
}
|
||||
.media-library__thumb-img,
|
||||
.media-library__thumb-video {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
.media-library__thumb-ph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 0.75rem;
|
||||
color: var(--text3);
|
||||
font-weight: 600;
|
||||
}
|
||||
.media-library__card-footer {
|
||||
padding: 10px 10px 12px;
|
||||
min-height: 0;
|
||||
}
|
||||
.media-library__card-name {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.35;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.media-library__card-tags {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 0.7rem;
|
||||
color: var(--text2);
|
||||
}
|
||||
.media-library__card-tags span {
|
||||
background: var(--surface2);
|
||||
padding: 2px 6px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
.media-library__table-wrap {
|
||||
overflow-x: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
background: var(--surface);
|
||||
}
|
||||
.media-library__table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
.media-library__table th,
|
||||
.media-library__table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--border);
|
||||
vertical-align: middle;
|
||||
}
|
||||
.media-library__table th {
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
font-size: 0.78rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
}
|
||||
.media-library__th-check {
|
||||
width: 40px;
|
||||
}
|
||||
.media-library__th-act {
|
||||
width: 52px;
|
||||
}
|
||||
.media-library__td-thumb {
|
||||
width: 72px;
|
||||
}
|
||||
.media-library__table-thumb {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.media-library__table-thumb-hit {
|
||||
display: block;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
cursor: zoom-in;
|
||||
font: inherit;
|
||||
border-radius: 8px;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
.media-library__table-thumb-hit:focus-visible {
|
||||
outline: 2px solid var(--accent, #3b82f6);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
.media-library__table-thumb .media-library__thumb-img,
|
||||
.media-library__table-thumb .media-library__thumb-video {
|
||||
width: 56px;
|
||||
height: 56px;
|
||||
}
|
||||
.media-library__td-name {
|
||||
font-weight: 500;
|
||||
max-width: 220px;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.media-library__td-sub {
|
||||
font-size: 0.8rem;
|
||||
color: var(--text2);
|
||||
max-width: 160px;
|
||||
}
|
||||
@media (max-width: 639px) {
|
||||
.media-library__table .media-library__td-sub,
|
||||
.media-library__table th:nth-child(5),
|
||||
.media-library__table td:nth-child(5) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.media-library__overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 200;
|
||||
background: rgba(0, 0, 0, 0.45);
|
||||
display: flex;
|
||||
align-items: flex-end;
|
||||
justify-content: center;
|
||||
padding: env(safe-area-inset-top, 0px) 0 0;
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.media-library__overlay {
|
||||
align-items: center;
|
||||
padding: 24px 16px;
|
||||
}
|
||||
}
|
||||
.media-library__modal {
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
max-height: min(92vh, 720px);
|
||||
overflow-y: auto;
|
||||
background: var(--surface);
|
||||
border-radius: 16px 16px 0 0;
|
||||
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
@media (min-width: 640px) {
|
||||
.media-library__modal {
|
||||
border-radius: 16px;
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
.media-library__modal--wide {
|
||||
max-width: 520px;
|
||||
}
|
||||
.media-library__modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 16px 18px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
background: var(--surface);
|
||||
z-index: 1;
|
||||
}
|
||||
.media-library__modal-head h2 {
|
||||
margin: 0;
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
.media-library__modal-body {
|
||||
padding: 16px 18px 24px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
.media-library__check {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
font-size: 0.9rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.media-library__meta-block {
|
||||
display: grid;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
background: var(--surface2);
|
||||
border-radius: 10px;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.media-library__meta-k {
|
||||
display: block;
|
||||
font-size: 0.72rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text3);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
.media-library__meta-v {
|
||||
color: var(--text1);
|
||||
word-break: break-word;
|
||||
}
|
||||
.media-library__meta-v.mono {
|
||||
font-family: ui-monospace, monospace;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
.media-library__modal-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
.media-library__lc-block {
|
||||
margin-top: 8px;
|
||||
padding-top: 16px;
|
||||
border-top: 1px solid var(--border);
|
||||
}
|
||||
.media-library__lc-block--danger {
|
||||
background: rgba(216, 90, 48, 0.06);
|
||||
margin: 8px -18px -24px;
|
||||
padding: 16px 18px 20px;
|
||||
border-radius: 0 0 16px 16px;
|
||||
}
|
||||
.media-library__lc-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--text2);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.media-library__lc-btns {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-library__row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
.media-library__row .form-input {
|
||||
flex: 1 1 160px;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
/* Vorschau-Modal (Vollbild nah) */
|
||||
.media-library__overlay--preview {
|
||||
background: rgba(0, 0, 0, 0.72);
|
||||
z-index: 210;
|
||||
}
|
||||
.media-library__modal--preview {
|
||||
max-width: min(92vw, 960px);
|
||||
width: 100%;
|
||||
max-height: min(94vh, 900px);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
.media-library__modal--preview .media-library__modal-head {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.media-library__preview-title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
margin-right: 8px;
|
||||
}
|
||||
.media-library__modal--preview .media-library__modal-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
.media-library__preview-head-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.media-library__preview-body {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 12px 16px 20px;
|
||||
background: var(--surface2);
|
||||
}
|
||||
.media-library__preview-img {
|
||||
max-width: 100%;
|
||||
max-height: min(78vh, 720px);
|
||||
width: auto;
|
||||
height: auto;
|
||||
object-fit: contain;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.12);
|
||||
}
|
||||
.media-library__preview-video {
|
||||
width: 100%;
|
||||
max-height: min(78vh, 720px);
|
||||
border-radius: 8px;
|
||||
background: #000;
|
||||
}
|
||||
.media-library__preview-fallback {
|
||||
text-align: center;
|
||||
padding: 24px 16px;
|
||||
max-width: 360px;
|
||||
}
|
||||
.media-library__preview-fallback .btn {
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { NavLink } from 'react-router-dom'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users } from 'lucide-react'
|
||||
import { TreePine, FolderTree, Download, Grid3x3, Users, Images } from 'lucide-react'
|
||||
|
||||
/**
|
||||
* Admin-Seiten-Navigation (horizontal)
|
||||
|
|
@ -11,6 +11,7 @@ export default function AdminPageNav() {
|
|||
{ to: '/admin/users', label: 'Nutzer', icon: Users },
|
||||
{ to: '/admin/maturity-models', label: 'Fähigkeitsmatrix', icon: Grid3x3 },
|
||||
{ to: '/admin/catalogs', label: 'Kataloge', icon: FolderTree },
|
||||
{ to: '/media', label: 'Medien', icon: Images },
|
||||
{ to: '/admin/mediawiki-import', label: 'Wiki-Import', icon: Download }
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -4,15 +4,7 @@
|
|||
import React from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
function resolveMediaUrl(filePath) {
|
||||
if (!filePath) return null
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
|
||||
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
|
||||
return `${API_BASE}${p}`
|
||||
}
|
||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||
|
||||
function HtmlBlock({ html, className = '' }) {
|
||||
if (!html || !String(html).trim()) return null
|
||||
|
|
@ -22,7 +14,7 @@ function HtmlBlock({ html, className = '' }) {
|
|||
)
|
||||
}
|
||||
|
||||
function MediaBlock({ media }) {
|
||||
function MediaBlock({ media, exerciseId }) {
|
||||
if (media.embed_url) {
|
||||
return (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
|
|
@ -37,7 +29,7 @@ function MediaBlock({ media }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const src = resolveMediaUrl(media.file_path)
|
||||
const src = resolveExerciseMediaFileUrl(exerciseId, media)
|
||||
if (!src) return null
|
||||
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
||||
return (
|
||||
|
|
@ -121,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 }}>
|
||||
|
|
@ -169,16 +165,21 @@ 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} />
|
||||
<MediaBlock media={m} exerciseId={exercise.id ?? exerciseId} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,58 +1,70 @@
|
|||
import { createContext, useContext, useState, useEffect } from 'react'
|
||||
import { useAuth } from './AuthContext'
|
||||
import { getCurrentProfile, listProfiles } from '../utils/api'
|
||||
|
||||
const ProfileContext = createContext(null)
|
||||
|
||||
export function ProfileProvider({ children }) {
|
||||
const { session } = useAuth()
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const { user, isAuthenticated } = useAuth()
|
||||
const [profiles, setProfiles] = useState([])
|
||||
const [activeProfile, setActiveProfileState] = useState(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [loading, setLoading] = useState(true)
|
||||
|
||||
const loadProfiles = async () => {
|
||||
const loadProfiles = async (authUser) => {
|
||||
try {
|
||||
const token = localStorage.getItem('bodytrack_token') || ''
|
||||
const res = await fetch('/api/profiles', {
|
||||
headers: { 'X-Auth-Token': token }
|
||||
})
|
||||
if (!res.ok) return []
|
||||
return await res.json()
|
||||
} catch(e) { return [] }
|
||||
if (!authUser?.id) return []
|
||||
const admin = authUser.role === 'admin' || authUser.role === 'superadmin'
|
||||
if (admin) {
|
||||
try {
|
||||
return await listProfiles()
|
||||
} catch {
|
||||
const me = await getCurrentProfile()
|
||||
return me ? [me] : []
|
||||
}
|
||||
}
|
||||
const me = await getCurrentProfile()
|
||||
return me ? [me] : []
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
// Re-load whenever session changes (login/logout/switch)
|
||||
useEffect(() => {
|
||||
if (!session) {
|
||||
if (!isAuthenticated || !user?.id) {
|
||||
setActiveProfileState(null)
|
||||
setProfiles([])
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
setLoading(true)
|
||||
loadProfiles().then(data => {
|
||||
setProfiles(data)
|
||||
// Always use the profile_id from the session token – not localStorage
|
||||
const match = data.find(p => p.id === session.profile_id)
|
||||
setActiveProfileState(match || data[0] || null)
|
||||
loadProfiles(user).then((data) => {
|
||||
const rows = Array.isArray(data) ? data : []
|
||||
setProfiles(rows)
|
||||
const uid = user.id
|
||||
const match = rows.find((p) => String(p.id) === String(uid))
|
||||
setActiveProfileState(match || rows[0] || null)
|
||||
setLoading(false)
|
||||
})
|
||||
}, [session?.profile_id]) // re-runs when profile changes
|
||||
}, [isAuthenticated, user?.id, user?.role])
|
||||
|
||||
const setActiveProfile = (profile) => {
|
||||
setActiveProfileState(profile)
|
||||
localStorage.setItem('bodytrack_active_profile', profile.id)
|
||||
localStorage.setItem('shinkan_active_profile', String(profile.id))
|
||||
}
|
||||
|
||||
const refreshProfiles = () => loadProfiles().then(data => {
|
||||
setProfiles(data)
|
||||
if (activeProfile) {
|
||||
const updated = data.find(p => p.id === activeProfile.id)
|
||||
if (updated) setActiveProfileState(updated)
|
||||
}
|
||||
})
|
||||
const refreshProfiles = () =>
|
||||
loadProfiles(user).then((data) => {
|
||||
setProfiles(Array.isArray(data) ? data : [])
|
||||
if (activeProfile) {
|
||||
const updated = data.find((p) => String(p.id) === String(activeProfile.id))
|
||||
if (updated) setActiveProfileState(updated)
|
||||
}
|
||||
})
|
||||
|
||||
return (
|
||||
<ProfileContext.Provider value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}>
|
||||
<ProfileContext.Provider
|
||||
value={{ profiles, activeProfile, setActiveProfile, refreshProfiles, loading }}
|
||||
>
|
||||
{children}
|
||||
</ProfileContext.Provider>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export default function AdminCatalogsPage() {
|
|||
} else if (activeTab === 'trainer-assignments') {
|
||||
const [assignments, profs, areas] = await Promise.all([
|
||||
api.listTrainerFocusAreas(),
|
||||
fetch('/api/profiles').then(r => r.json()),
|
||||
api.listProfiles(),
|
||||
api.listFocusAreas()
|
||||
])
|
||||
setTrainerAssignments(assignments)
|
||||
|
|
|
|||
|
|
@ -1,18 +1,10 @@
|
|||
import React, { useEffect, useState } from 'react'
|
||||
import { Link, useNavigate, useParams } from 'react-router-dom'
|
||||
import api from '../utils/api'
|
||||
import { resolveExerciseMediaFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import { sanitizeTrainerHtml } from '../utils/htmlUtils'
|
||||
import { formatSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
function resolveMediaUrl(filePath) {
|
||||
if (!filePath) return null
|
||||
if (filePath.startsWith('http://') || filePath.startsWith('https://')) return filePath
|
||||
const p = filePath.startsWith('/') ? filePath : `/${filePath}`
|
||||
return `${API_BASE}${p}`
|
||||
}
|
||||
|
||||
function HtmlBlock({ html, className = '' }) {
|
||||
if (!html || !String(html).trim()) return null
|
||||
const safe = sanitizeTrainerHtml(html)
|
||||
|
|
@ -24,7 +16,7 @@ function HtmlBlock({ html, className = '' }) {
|
|||
)
|
||||
}
|
||||
|
||||
function MediaBlock({ media }) {
|
||||
function MediaBlock({ media, exerciseId }) {
|
||||
if (media.embed_url) {
|
||||
return (
|
||||
<div style={{ marginTop: '0.5rem' }}>
|
||||
|
|
@ -39,7 +31,7 @@ function MediaBlock({ media }) {
|
|||
</div>
|
||||
)
|
||||
}
|
||||
const src = resolveMediaUrl(media.file_path)
|
||||
const src = resolveExerciseMediaFileUrl(exerciseId, media)
|
||||
if (!src) return null
|
||||
if (media.media_type === 'image' || (media.mime_type && media.mime_type.startsWith('image/'))) {
|
||||
return (
|
||||
|
|
@ -159,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' }}>
|
||||
|
|
@ -219,14 +215,19 @@ 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} />
|
||||
<MediaBlock media={m} exerciseId={exercise.id} />
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -1,10 +1,75 @@
|
|||
import React, { useEffect, useState, useRef } from 'react'
|
||||
import { useNavigate, useParams } from 'react-router-dom'
|
||||
import React, { useEffect, useState, useRef, useMemo } from 'react'
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||
import api, { buildExerciseApiPayload } from '../utils/api'
|
||||
import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||
import RichTextEditor from '../components/RichTextEditor'
|
||||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
|
||||
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
|
||||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
||||
const commonStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Vorschau"
|
||||
onClick={() => onOpenPreview(media)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenPreview(media)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
flexShrink: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||||
border: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{media.embed_url ? (
|
||||
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
||||
{media.embed_platform || 'Embed'}
|
||||
</span>
|
||||
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
|
||||
<img alt="" src={src} style={commonStyle} />
|
||||
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
|
||||
<video
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ ...commonStyle, pointerEvents: 'none' }}
|
||||
onLoadedMetadata={(e) => {
|
||||
try {
|
||||
const el = e.currentTarget
|
||||
const d = el.duration
|
||||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: 'niedrig', label: 'niedrig' },
|
||||
|
|
@ -367,6 +432,50 @@ function ExerciseFormPage() {
|
|||
const [mediaContext, setMediaContext] = useState('ablauf')
|
||||
const [embedUrl, setEmbedUrl] = useState('')
|
||||
const [embedTitle, setEmbedTitle] = useState('')
|
||||
const [mediaFields, setMediaFields] = useState({})
|
||||
const [mediaSavingId, setMediaSavingId] = useState(null)
|
||||
const [archiveOpen, setArchiveOpen] = useState(false)
|
||||
const [archiveQ, setArchiveQ] = useState('')
|
||||
const [archiveCtx, setArchiveCtx] = useState('ablauf')
|
||||
const [archiveLoading, setArchiveLoading] = useState(false)
|
||||
const [archiveItems, setArchiveItems] = useState([])
|
||||
const [archiveError, setArchiveError] = useState(null)
|
||||
const [mediaPreview, setMediaPreview] = useState(null)
|
||||
|
||||
useEffect(() => {
|
||||
const next = {}
|
||||
for (const m of mediaList) {
|
||||
next[m.id] = {
|
||||
title: m.title || '',
|
||||
context: m.context || 'ablauf',
|
||||
}
|
||||
}
|
||||
setMediaFields(next)
|
||||
}, [mediaList])
|
||||
|
||||
useEffect(() => {
|
||||
if (!archiveOpen) return undefined
|
||||
let cancelled = false
|
||||
const t = setTimeout(async () => {
|
||||
setArchiveLoading(true)
|
||||
setArchiveError(null)
|
||||
try {
|
||||
const res = await api.listMediaAssets({
|
||||
q: archiveQ.trim() || undefined,
|
||||
limit: 40,
|
||||
})
|
||||
if (!cancelled) setArchiveItems(res.items || [])
|
||||
} catch (e) {
|
||||
if (!cancelled) setArchiveError(e.message || String(e))
|
||||
} finally {
|
||||
if (!cancelled) setArchiveLoading(false)
|
||||
}
|
||||
}, 280)
|
||||
return () => {
|
||||
cancelled = true
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [archiveOpen, archiveQ])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
|
|
@ -523,7 +632,54 @@ function ExerciseFormPage() {
|
|||
setSaving(true)
|
||||
try {
|
||||
if (isEdit) {
|
||||
await api.updateExercise(exerciseId, payload)
|
||||
const saveOnce = (extras = {}) =>
|
||||
api.updateExercise(exerciseId, buildExerciseApiPayload(payloadBase, extras))
|
||||
try {
|
||||
await saveOnce()
|
||||
} catch (firstErr) {
|
||||
if (
|
||||
firstErr.status === 422 &&
|
||||
firstErr.code === 'OFFICIAL_MEDIA_LIFECYCLE' &&
|
||||
firstErr.payload?.media_assets
|
||||
) {
|
||||
alert(
|
||||
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). ' +
|
||||
'Bitte Medium wiederherstellen oder aus der Übung entfernen.',
|
||||
)
|
||||
throw firstErr
|
||||
}
|
||||
if (firstErr.status === 422 && firstErr.code === 'OFFICIAL_MEDIA_CONFIRM_REQUIRED') {
|
||||
const promo = (firstErr.payload.assets_need_visibility_promotion || []).length
|
||||
const miss = (firstErr.payload.assets_missing_copyright || []).length
|
||||
let msg =
|
||||
'Die Übung ist oder wird offiziell. '
|
||||
if (promo > 0) {
|
||||
msg += `${promo} verknüpfte Datei(en) werden dabei plattformweit („offiziell“) freigegeben. `
|
||||
}
|
||||
if (miss > 0) {
|
||||
msg += `${miss} Datei(en) haben noch keinen ausreichenden Copyright-Vermerk (mind. 3 Zeichen). `
|
||||
}
|
||||
msg += 'Fortfahren?'
|
||||
if (!window.confirm(msg)) throw firstErr
|
||||
let defaultCopyright = ''
|
||||
if (miss > 0) {
|
||||
defaultCopyright = window.prompt(
|
||||
'Copyright-Vermerk für betroffene Dateien ohne Eintrag (mind. 3 Zeichen):',
|
||||
'© ',
|
||||
)
|
||||
if (!defaultCopyright || String(defaultCopyright).trim().length < 3) {
|
||||
alert('Mindestens 3 Zeichen für den Copyright-Vermerk.')
|
||||
throw firstErr
|
||||
}
|
||||
}
|
||||
await saveOnce({
|
||||
promote_attached_media_for_official: true,
|
||||
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
|
||||
})
|
||||
} else {
|
||||
throw firstErr
|
||||
}
|
||||
}
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
setMediaList(ex.media || [])
|
||||
setVariants((ex.variants || []).map(apiVariantToRow))
|
||||
|
|
@ -545,6 +701,28 @@ function ExerciseFormPage() {
|
|||
setMediaList(ex.media || [])
|
||||
}
|
||||
|
||||
const attachFromArchive = async (assetId) => {
|
||||
if (!exerciseId) return
|
||||
try {
|
||||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||||
media_asset_id: assetId,
|
||||
context: archiveCtx,
|
||||
title: '',
|
||||
description: '',
|
||||
is_primary: false,
|
||||
})
|
||||
setArchiveOpen(false)
|
||||
await refreshMedia()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const linkedArchiveAssetIds = useMemo(
|
||||
() => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)),
|
||||
[mediaList],
|
||||
)
|
||||
|
||||
const handleUploadFile = async () => {
|
||||
if (!exerciseId || !mediaFile) {
|
||||
alert('Datei wählen')
|
||||
|
|
@ -563,7 +741,44 @@ function ExerciseFormPage() {
|
|||
setMediaTitle('')
|
||||
await refreshMedia()
|
||||
} catch (err) {
|
||||
alert('Upload: ' + err.message)
|
||||
if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
|
||||
const aid = err.payload.media_asset_id
|
||||
const nameHint =
|
||||
(mediaFile && mediaFile.name) ||
|
||||
err.payload.original_filename ||
|
||||
'diese Datei'
|
||||
if (
|
||||
confirm(
|
||||
`Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` +
|
||||
'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)',
|
||||
)
|
||||
) {
|
||||
try {
|
||||
await api.postMediaAssetLifecycle(aid, 'reactivate')
|
||||
await api.attachExerciseMediaFromAsset(exerciseId, {
|
||||
media_asset_id: aid,
|
||||
title: mediaTitle || undefined,
|
||||
description: '',
|
||||
context: mediaContext,
|
||||
is_primary: false,
|
||||
})
|
||||
setMediaFile(null)
|
||||
setMediaTitle('')
|
||||
await refreshMedia()
|
||||
} catch (e2) {
|
||||
alert(e2.message || String(e2))
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if (err.code === 'MEDIA_ASSET_UNAVAILABLE') {
|
||||
alert(
|
||||
(err.message || 'Archiv-Konflikt') +
|
||||
' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.',
|
||||
)
|
||||
return
|
||||
}
|
||||
alert('Upload: ' + (err.message || String(err)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -590,15 +805,69 @@ function ExerciseFormPage() {
|
|||
}
|
||||
|
||||
const handleDeleteMedia = async (mid) => {
|
||||
if (!confirm('Medium löschen?')) return
|
||||
if (
|
||||
!confirm(
|
||||
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteExerciseMedia(exerciseId, mid)
|
||||
const res = await api.deleteExerciseMedia(exerciseId, mid)
|
||||
await refreshMedia()
|
||||
const oid = res?.orphan_media_asset_id
|
||||
if (oid != null) {
|
||||
if (
|
||||
confirm(
|
||||
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
|
||||
)
|
||||
) {
|
||||
await api.postMediaAssetLifecycle(oid, 'trash_soft')
|
||||
await refreshMedia()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
const moveMediaRow = async (idx, dir) => {
|
||||
if (!exerciseId) return
|
||||
const j = idx + dir
|
||||
if (j < 0 || j >= mediaList.length) return
|
||||
const next = [...mediaList]
|
||||
const tmp = next[idx]
|
||||
next[idx] = next[j]
|
||||
next[j] = tmp
|
||||
try {
|
||||
await api.reorderExerciseMedia(
|
||||
exerciseId,
|
||||
next.map((x) => x.id),
|
||||
)
|
||||
setMediaList(next)
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
}
|
||||
}
|
||||
|
||||
const saveMediaMeta = async (mid) => {
|
||||
if (!exerciseId) return
|
||||
const fld = mediaFields[mid]
|
||||
if (!fld) return
|
||||
setMediaSavingId(mid)
|
||||
try {
|
||||
await api.updateExerciseMedia(exerciseId, mid, {
|
||||
title: fld.title.trim() || null,
|
||||
context: fld.context,
|
||||
})
|
||||
await refreshMedia()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setMediaSavingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const refreshVariants = async () => {
|
||||
if (!exerciseId) return
|
||||
const ex = await api.getExercise(exerciseId)
|
||||
|
|
@ -1170,6 +1439,22 @@ function ExerciseFormPage() {
|
|||
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
|
||||
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
gap: '8px',
|
||||
alignItems: 'center',
|
||||
marginTop: '10px',
|
||||
}}
|
||||
>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(true)}>
|
||||
Aus Archiv verknüpfen…
|
||||
</button>
|
||||
<Link to="/media" className="btn btn-secondary" style={{ textDecoration: 'none' }}>
|
||||
Medienbibliothek
|
||||
</Link>
|
||||
</div>
|
||||
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
|
||||
<div>
|
||||
<label className="form-label">Datei</label>
|
||||
|
|
@ -1230,30 +1515,308 @@ function ExerciseFormPage() {
|
|||
</div>
|
||||
</div>
|
||||
{mediaList.length > 0 && (
|
||||
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
|
||||
{mediaList.map((m) => (
|
||||
<li key={m.id} style={{ marginBottom: '6px' }}>
|
||||
{m.title || m.original_filename || m.media_type}{' '}
|
||||
{m.embed_platform ? `(${m.embed_platform})` : ''}
|
||||
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
|
||||
{mediaList.map((m, idx) => (
|
||||
<li
|
||||
key={m.id}
|
||||
className="card"
|
||||
style={{
|
||||
marginBottom: '10px',
|
||||
padding: '10px 12px',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
gap: '12px',
|
||||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
#{idx + 1} · {m.media_type}
|
||||
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
|
||||
</span>
|
||||
{mediaList.length > 1 && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveMediaRow(idx, -1)}
|
||||
title="Nach oben"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '2px 8px' }}
|
||||
disabled={idx >= mediaList.length - 1}
|
||||
onClick={() => moveMediaRow(idx, 1)}
|
||||
title="Nach unten"
|
||||
>
|
||||
↓
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
marginTop: '6px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{(m.original_filename || '').trim() ||
|
||||
(m.title || '').trim() ||
|
||||
(m.embed_url ? m.embed_url : '') ||
|
||||
'—'}
|
||||
</div>
|
||||
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
className="form-input"
|
||||
placeholder="Titel"
|
||||
value={(mediaFields[m.id] || {}).title ?? ''}
|
||||
onChange={(e) =>
|
||||
setMediaFields((prev) => ({
|
||||
...prev,
|
||||
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
|
||||
}))
|
||||
}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
value={(mediaFields[m.id] || {}).context || 'ablauf'}
|
||||
onChange={(e) =>
|
||||
setMediaFields((prev) => ({
|
||||
...prev,
|
||||
[m.id]: {
|
||||
...(prev[m.id] || {}),
|
||||
title: (prev[m.id] || {}).title ?? '',
|
||||
context: e.target.value,
|
||||
},
|
||||
}))
|
||||
}
|
||||
>
|
||||
<option value="ablauf">Ablauf</option>
|
||||
<option value="detail">Detail</option>
|
||||
<option value="trainer_hint">Trainer-Hinweis</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={mediaSavingId === m.id}
|
||||
onClick={() => saveMediaMeta(m.id)}
|
||||
>
|
||||
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
marginLeft: '8px',
|
||||
fontSize: '11px',
|
||||
padding: '2px 8px',
|
||||
background: 'var(--danger)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
marginTop: '8px',
|
||||
fontSize: '12px',
|
||||
padding: '6px 12px',
|
||||
}}
|
||||
onClick={() => handleDeleteMedia(m.id)}
|
||||
>
|
||||
Löschen
|
||||
Aus Übung entfernen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{archiveOpen && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Medienarchiv"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.5)',
|
||||
zIndex: 1000,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
}}
|
||||
onClick={() => setArchiveOpen(false)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setArchiveOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{
|
||||
maxWidth: 560,
|
||||
margin: '4vh auto',
|
||||
maxHeight: '88vh',
|
||||
overflow: 'auto',
|
||||
position: 'relative',
|
||||
}}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Medienarchiv</h3>
|
||||
<input
|
||||
type="search"
|
||||
className="form-input"
|
||||
placeholder="Suche Dateiname…"
|
||||
value={archiveQ}
|
||||
onChange={(e) => setArchiveQ(e.target.value)}
|
||||
style={{ marginBottom: '8px' }}
|
||||
/>
|
||||
<select
|
||||
className="form-input"
|
||||
value={archiveCtx}
|
||||
onChange={(e) => setArchiveCtx(e.target.value)}
|
||||
style={{ marginBottom: '12px' }}
|
||||
>
|
||||
<option value="ablauf">Sektion: Ablauf</option>
|
||||
<option value="detail">Sektion: Detail</option>
|
||||
<option value="trainer_hint">Sektion: Trainer-Hinweis</option>
|
||||
</select>
|
||||
{archiveLoading && <p style={{ fontSize: '13px', color: 'var(--text3)' }}>Laden…</p>}
|
||||
{archiveError && <p style={{ fontSize: '13px', color: 'var(--danger)' }}>{archiveError}</p>}
|
||||
{!archiveLoading && !archiveError && archiveItems.length === 0 && (
|
||||
<p style={{ fontSize: '13px', color: 'var(--text3)' }}>Keine Treffer.</p>
|
||||
)}
|
||||
<ul style={{ listStyle: 'none', padding: 0, margin: 0 }}>
|
||||
{archiveItems.map((a) => {
|
||||
const already = linkedArchiveAssetIds.has(a.id)
|
||||
return (
|
||||
<li
|
||||
key={a.id}
|
||||
style={{
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
alignItems: 'center',
|
||||
padding: '8px 0',
|
||||
borderBottom: '1px solid var(--border)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
width: 56,
|
||||
height: 56,
|
||||
flexShrink: 0,
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||||
border: '1px solid var(--border)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{a.mime_type?.startsWith('image/') ? (
|
||||
<img
|
||||
alt=""
|
||||
src={resolveMediaAssetFileUrl(a.id)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : a.mime_type?.startsWith('video/') ? (
|
||||
<span style={{ fontSize: '18px', opacity: 0.75 }} aria-hidden>
|
||||
▶
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '10px', color: 'var(--text2)', padding: '2px', textAlign: 'center' }}>
|
||||
PDF
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ fontSize: '13px', fontWeight: 600, wordBreak: 'break-word' }}>
|
||||
{a.original_filename || `Asset #${a.id}`}
|
||||
</div>
|
||||
<div style={{ fontSize: '11px', color: 'var(--text3)', marginTop: '2px' }}>
|
||||
{a.visibility} · {a.mime_type || '—'}{' '}
|
||||
{a.byte_size != null ? `· ${(a.byte_size / 1024).toFixed(0)} KB` : ''}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
style={{ fontSize: '12px' }}
|
||||
disabled={already}
|
||||
title={already ? 'Schon mit dieser Übung verknüpft' : ''}
|
||||
onClick={() => !already && attachFromArchive(a.id)}
|
||||
>
|
||||
{already ? 'Bereits verknüpft' : 'Verknüpfen'}
|
||||
</button>
|
||||
</li>
|
||||
)
|
||||
})}
|
||||
</ul>
|
||||
<div style={{ marginTop: '16px', display: 'flex', justifyContent: 'flex-end', gap: '8px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setArchiveOpen(false)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{mediaPreview && (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="Medienvorschau"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
background: 'rgba(0,0,0,0.55)',
|
||||
zIndex: 1001,
|
||||
overflow: 'auto',
|
||||
padding: '16px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
onClick={() => setMediaPreview(null)}
|
||||
onKeyDown={(e) => e.key === 'Escape' && setMediaPreview(null)}
|
||||
>
|
||||
<div
|
||||
className="card"
|
||||
style={{ maxWidth: 720, width: '100%', maxHeight: '90vh', overflow: 'auto' }}
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<h3 style={{ marginTop: 0, fontSize: '1.05rem' }}>Vorschau</h3>
|
||||
{mediaPreview.embed_url ? (
|
||||
<p style={{ fontSize: '14px', wordBreak: 'break-all' }}>
|
||||
<a href={mediaPreview.embed_url} target="_blank" rel="noreferrer">
|
||||
{mediaPreview.embed_url}
|
||||
</a>
|
||||
</p>
|
||||
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
|
||||
<video
|
||||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
|
||||
/>
|
||||
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
|
||||
<img
|
||||
alt=""
|
||||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||||
style={{ maxWidth: '100%', borderRadius: '8px', maxHeight: '70vh', objectFit: 'contain' }}
|
||||
/>
|
||||
) : (
|
||||
<p style={{ fontSize: '14px' }}>
|
||||
<a href={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)} target="_blank" rel="noreferrer">
|
||||
Datei öffnen
|
||||
</a>
|
||||
</p>
|
||||
)}
|
||||
<div style={{ marginTop: '16px' }}>
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setMediaPreview(null)}>
|
||||
Schließen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
|
|
|||
893
frontend/src/pages/MediaLibraryPage.jsx
Normal file
893
frontend/src/pages/MediaLibraryPage.jsx
Normal file
|
|
@ -0,0 +1,893 @@
|
|||
import { useEffect, useState, useCallback } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
import {
|
||||
LayoutGrid,
|
||||
List,
|
||||
MoreVertical,
|
||||
X,
|
||||
} from 'lucide-react'
|
||||
import { useAuth } from '../context/AuthContext'
|
||||
import api from '../utils/api'
|
||||
import AdminPageNav from '../components/AdminPageNav'
|
||||
import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl'
|
||||
|
||||
const LC_OPTIONS = [
|
||||
{ value: 'active', label: 'Aktiv' },
|
||||
{ value: 'trash_soft', label: 'Papierkorb (1)' },
|
||||
{ value: 'trash_hidden', label: 'Ausgeblendet (2)' },
|
||||
{ value: 'all', label: 'Alle' },
|
||||
]
|
||||
|
||||
const VIS_OPTIONS = [
|
||||
{ value: 'private', label: 'Privat' },
|
||||
{ value: 'club', label: 'Verein' },
|
||||
{ value: 'official', label: 'Offiziell' },
|
||||
]
|
||||
|
||||
function lcLabel(code) {
|
||||
const o = LC_OPTIONS.find((x) => x.value === code)
|
||||
return o ? o.label : code
|
||||
}
|
||||
|
||||
function uploaderLabel(it, viewer) {
|
||||
if (!viewer?.show_uploader_meta) return null
|
||||
const n = (it.uploader_name || '').trim()
|
||||
const e = (it.uploader_email || '').trim()
|
||||
if (n) return n
|
||||
if (e) return e
|
||||
return it.uploaded_by_profile_id != null ? `Profil #${it.uploaded_by_profile_id}` : '—'
|
||||
}
|
||||
|
||||
function MediaThumb({ mediaId, mimeType }) {
|
||||
const url = resolveMediaAssetFileUrl(mediaId)
|
||||
const mime = (mimeType || '').toLowerCase()
|
||||
if (mime.startsWith('video/')) {
|
||||
return (
|
||||
<video
|
||||
className="media-library__thumb-video"
|
||||
src={url}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
onLoadedMetadata={(e) => {
|
||||
const el = e.target
|
||||
try {
|
||||
const d = el.duration
|
||||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mime.startsWith('image/')) {
|
||||
return (
|
||||
<img
|
||||
className="media-library__thumb-img"
|
||||
src={url}
|
||||
alt=""
|
||||
loading="lazy"
|
||||
onError={(e) => {
|
||||
e.target.style.display = 'none'
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (mime.includes('pdf')) {
|
||||
return <div className="media-library__thumb-ph">PDF</div>
|
||||
}
|
||||
return <div className="media-library__thumb-ph">◆</div>
|
||||
}
|
||||
|
||||
function previewDisplayKind(mimeType) {
|
||||
const m = (mimeType || '').toLowerCase()
|
||||
if (m.startsWith('image/')) return 'image'
|
||||
if (m.startsWith('video/')) return 'video'
|
||||
if (m.includes('pdf')) return 'pdf'
|
||||
return 'other'
|
||||
}
|
||||
|
||||
export default function MediaLibraryPage() {
|
||||
const { user } = useAuth()
|
||||
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
|
||||
const isSuperadmin = user?.role === 'superadmin'
|
||||
|
||||
const [lifecycle, setLifecycle] = useState('active')
|
||||
const [q, setQ] = useState('')
|
||||
const [items, setItems] = useState([])
|
||||
const [viewer, setViewer] = useState(null)
|
||||
const [clubs, setClubs] = useState([])
|
||||
const [viewMode, setViewMode] = useState('grid')
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState('')
|
||||
const [selected, setSelected] = useState(() => new Set())
|
||||
const [modal, setModal] = useState(null)
|
||||
const [modalDraft, setModalDraft] = useState(null)
|
||||
const [bulkOpen, setBulkOpen] = useState(false)
|
||||
const [bulkCopyright, setBulkCopyright] = useState('')
|
||||
const [bulkVis, setBulkVis] = useState('private')
|
||||
const [bulkClubId, setBulkClubId] = useState('')
|
||||
const [bulkApplyVis, setBulkApplyVis] = useState(false)
|
||||
const [busy, setBusy] = useState(false)
|
||||
const [preview, setPreview] = useState(null)
|
||||
|
||||
const loadClubs = useCallback(async () => {
|
||||
try {
|
||||
const c = await api.listClubs()
|
||||
setClubs(Array.isArray(c) ? c : [])
|
||||
} catch {
|
||||
setClubs([])
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
loadClubs()
|
||||
}, [loadClubs])
|
||||
|
||||
const loadItems = useCallback(async () => {
|
||||
setLoading(true)
|
||||
setError('')
|
||||
try {
|
||||
const res = await api.listMediaAssets({
|
||||
lifecycle,
|
||||
q: q.trim(),
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
})
|
||||
setItems(res.items || [])
|
||||
setViewer(res.viewer || null)
|
||||
setSelected(new Set())
|
||||
} catch (e) {
|
||||
setError(e.message || String(e))
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}, [lifecycle, q])
|
||||
|
||||
useEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
loadItems()
|
||||
}, 320)
|
||||
return () => clearTimeout(t)
|
||||
}, [lifecycle, q, loadItems])
|
||||
|
||||
useEffect(() => {
|
||||
if (!preview) return
|
||||
const onKey = (e) => {
|
||||
if (e.key === 'Escape') setPreview(null)
|
||||
}
|
||||
window.addEventListener('keydown', onKey)
|
||||
return () => window.removeEventListener('keydown', onKey)
|
||||
}, [preview])
|
||||
|
||||
const toggleSel = (id) => {
|
||||
setSelected((prev) => {
|
||||
const n = new Set(prev)
|
||||
if (n.has(id)) n.delete(id)
|
||||
else n.add(id)
|
||||
return n
|
||||
})
|
||||
}
|
||||
|
||||
const selectAll = () => {
|
||||
if (selected.size === items.length) setSelected(new Set())
|
||||
else setSelected(new Set(items.map((x) => x.id)))
|
||||
}
|
||||
|
||||
const openEdit = (it) => {
|
||||
setPreview(null)
|
||||
const p = it.permissions || {}
|
||||
setModal(it)
|
||||
setModalDraft({
|
||||
display_name: it.original_filename != null ? String(it.original_filename) : '',
|
||||
copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '',
|
||||
visibility: (it.visibility || 'private').toLowerCase(),
|
||||
club_id: it.club_id != null ? String(it.club_id) : '',
|
||||
superTarget: 'active',
|
||||
})
|
||||
}
|
||||
|
||||
const closeModal = () => {
|
||||
setModal(null)
|
||||
setModalDraft(null)
|
||||
}
|
||||
|
||||
const saveModal = async () => {
|
||||
if (!modal || !modalDraft) return
|
||||
const p = modal.permissions || {}
|
||||
setBusy(true)
|
||||
try {
|
||||
const body = {}
|
||||
if (p.edit_metadata) {
|
||||
body.original_filename = modalDraft.display_name
|
||||
body.copyright_notice = modalDraft.copyright_notice
|
||||
}
|
||||
if (p.change_visibility) {
|
||||
body.visibility = modalDraft.visibility
|
||||
if (modalDraft.visibility === 'club') {
|
||||
const cid = Number(modalDraft.club_id)
|
||||
if (!cid) {
|
||||
alert('Bitte einen Verein wählen.')
|
||||
setBusy(false)
|
||||
return
|
||||
}
|
||||
body.club_id = cid
|
||||
}
|
||||
}
|
||||
if (Object.keys(body).length) {
|
||||
await api.patchMediaAsset(modal.id, body)
|
||||
}
|
||||
closeModal()
|
||||
await loadItems()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runLc = async (id, action, confirmMsg, extra = {}) => {
|
||||
if (confirmMsg && !window.confirm(confirmMsg)) return
|
||||
setBusy(true)
|
||||
try {
|
||||
await api.postMediaAssetLifecycle(id, action, extra)
|
||||
await loadItems()
|
||||
closeModal()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runBulkPatch = async () => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
const body = { media_asset_ids: ids }
|
||||
let has = false
|
||||
if (bulkCopyright.trim()) {
|
||||
body.copyright_notice = bulkCopyright.trim()
|
||||
has = true
|
||||
}
|
||||
if (bulkApplyVis) {
|
||||
body.visibility = bulkVis
|
||||
if (bulkVis === 'club') {
|
||||
const cid = Number(bulkClubId)
|
||||
if (!cid) {
|
||||
alert('Bitte einen Verein wählen.')
|
||||
return
|
||||
}
|
||||
body.club_id = cid
|
||||
}
|
||||
has = true
|
||||
}
|
||||
if (!has) {
|
||||
alert('Mindestens Copyright ausfüllen oder „Sichtbarkeit ändern“ aktivieren.')
|
||||
return
|
||||
}
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.bulkPatchMediaAssets(body)
|
||||
if (res.failed_count) {
|
||||
alert(
|
||||
`${res.updated_count} aktualisiert, ${res.failed_count} fehlgeschlagen. Erste Meldung: ${res.failed[0]?.detail || ''}`,
|
||||
)
|
||||
}
|
||||
setBulkOpen(false)
|
||||
await loadItems()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const runBulkLifecycle = async (action) => {
|
||||
const ids = [...selected]
|
||||
if (!ids.length) return
|
||||
setBusy(true)
|
||||
try {
|
||||
const res = await api.bulkMediaLifecycle({ media_asset_ids: ids, action })
|
||||
if (res.failed_count) {
|
||||
alert(
|
||||
`${res.updated_count} OK, ${res.failed_count} fehlgeschlagen: ${res.failed[0]?.detail || ''}`,
|
||||
)
|
||||
}
|
||||
await loadItems()
|
||||
} catch (e) {
|
||||
alert(e.message || String(e))
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
}
|
||||
|
||||
const selCount = selected.size
|
||||
|
||||
return (
|
||||
<div className="app-page media-library">
|
||||
{isPlatformAdmin ? <AdminPageNav /> : null}
|
||||
|
||||
<div className="media-library__container">
|
||||
<header className="media-library__hero">
|
||||
<div className="media-library__hero-row">
|
||||
<h1 className="media-library__title">Medienbibliothek</h1>
|
||||
<div className="media-library__hero-links">
|
||||
<Link to="/exercises">Übungen</Link>
|
||||
{isPlatformAdmin ? <Link to="/admin/hierarchy">Admin</Link> : null}
|
||||
</div>
|
||||
</div>
|
||||
<p className="media-library__intro">
|
||||
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Vorschau: Bild oder Video groß
|
||||
anklicken. Bearbeiten und Papierkorb über das Menü pro Medium — Bulk unten in der Leiste.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<div className="media-library__toolbar">
|
||||
<div className="media-library__toolbar-row">
|
||||
<input
|
||||
type="search"
|
||||
className="form-input media-library__search"
|
||||
placeholder="Suche Dateiname …"
|
||||
value={q}
|
||||
onChange={(e) => setQ(e.target.value)}
|
||||
aria-label="Suche"
|
||||
/>
|
||||
<select
|
||||
className="form-input media-library__select"
|
||||
value={lifecycle}
|
||||
onChange={(e) => setLifecycle(e.target.value)}
|
||||
aria-label="Lebenszyklus"
|
||||
>
|
||||
{LC_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<div className="media-library__view-toggle" role="group" aria-label="Darstellung">
|
||||
<button
|
||||
type="button"
|
||||
className={`media-library__icon-btn${viewMode === 'grid' ? ' media-library__icon-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('grid')}
|
||||
title="Kacheln"
|
||||
>
|
||||
<LayoutGrid size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`media-library__icon-btn${viewMode === 'list' ? ' media-library__icon-btn--on' : ''}`}
|
||||
onClick={() => setViewMode('list')}
|
||||
title="Liste"
|
||||
>
|
||||
<List size={20} />
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary media-library__refresh"
|
||||
onClick={loadItems}
|
||||
disabled={loading}
|
||||
>
|
||||
Aktualisieren
|
||||
</button>
|
||||
</div>
|
||||
<div className="media-library__toolbar-meta">
|
||||
<label className="media-library__check-all">
|
||||
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
|
||||
<span>alle sichtbaren</span>
|
||||
</label>
|
||||
{selCount > 0 ? (
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setBulkOpen(true)}>
|
||||
Bulk ({selCount})
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<p className="media-library__err" role="alert">
|
||||
{error}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{loading && !items.length ? <div className="spinner media-library__spinner" /> : null}
|
||||
|
||||
{!loading && !items.length && !error ? (
|
||||
<p className="media-library__empty">Keine Medien für diese Filter.</p>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'grid' && items.length > 0 ? (
|
||||
<div className="media-library__grid">
|
||||
{items.map((it) => {
|
||||
const lc = (it.lifecycle_state || '').toLowerCase()
|
||||
const chk = selected.has(it.id)
|
||||
return (
|
||||
<div key={it.id} className="media-library__card">
|
||||
<label className="media-library__card-check">
|
||||
<input type="checkbox" checked={chk} onChange={() => toggleSel(it.id)} />
|
||||
</label>
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__card-menu"
|
||||
onClick={() => openEdit(it)}
|
||||
title="Bearbeiten"
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__card-thumb-hit"
|
||||
onClick={() => setPreview(it)}
|
||||
title="Vorschau"
|
||||
>
|
||||
<div className="media-library__card-thumb-wrap">
|
||||
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
||||
</div>
|
||||
</button>
|
||||
<div className="media-library__card-footer">
|
||||
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
|
||||
{it.original_filename || `Medium #${it.id}`}
|
||||
</div>
|
||||
<div className="media-library__card-tags">
|
||||
<span>{it.visibility}</span>
|
||||
<span>{lcLabel(lc)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{viewMode === 'list' && items.length > 0 ? (
|
||||
<div className="media-library__table-wrap">
|
||||
<table className="media-library__table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th className="media-library__th-check" />
|
||||
<th>Vorschau</th>
|
||||
<th>Bezeichnung</th>
|
||||
<th>Sichtbarkeit</th>
|
||||
<th>Status</th>
|
||||
{viewer?.show_club_meta ? <th>Verein</th> : null}
|
||||
{viewer?.show_uploader_meta ? <th>Uploader</th> : null}
|
||||
<th className="media-library__th-act" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((it) => {
|
||||
const lc = (it.lifecycle_state || '').toLowerCase()
|
||||
return (
|
||||
<tr key={it.id}>
|
||||
<td>
|
||||
<input type="checkbox" checked={selected.has(it.id)} onChange={() => toggleSel(it.id)} />
|
||||
</td>
|
||||
<td className="media-library__td-thumb">
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__table-thumb-hit"
|
||||
onClick={() => setPreview(it)}
|
||||
title="Vorschau"
|
||||
>
|
||||
<div className="media-library__table-thumb">
|
||||
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
|
||||
</div>
|
||||
</button>
|
||||
</td>
|
||||
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
|
||||
<td>{it.visibility}</td>
|
||||
<td>{lcLabel(lc)}</td>
|
||||
{viewer?.show_club_meta ? (
|
||||
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
|
||||
) : null}
|
||||
{viewer?.show_uploader_meta ? (
|
||||
<td className="media-library__td-sub">{uploaderLabel(it, viewer) || '—'}</td>
|
||||
) : null}
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__icon-btn"
|
||||
onClick={() => openEdit(it)}
|
||||
aria-label="Bearbeiten"
|
||||
>
|
||||
<MoreVertical size={18} />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{preview ? (
|
||||
<div
|
||||
className="media-library__overlay media-library__overlay--preview"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="preview-media-title"
|
||||
onClick={() => setPreview(null)}
|
||||
>
|
||||
<div
|
||||
className="media-library__modal media-library__modal--preview"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="media-library__modal-head">
|
||||
<h2 id="preview-media-title" className="media-library__preview-title">
|
||||
{preview.original_filename || `Medium #${preview.id}`}
|
||||
</h2>
|
||||
<div className="media-library__preview-head-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary btn-small"
|
||||
onClick={() => {
|
||||
openEdit(preview)
|
||||
}}
|
||||
>
|
||||
Bearbeiten
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="media-library__icon-btn"
|
||||
onClick={() => setPreview(null)}
|
||||
aria-label="Vorschau schließen"
|
||||
>
|
||||
<X size={22} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="media-library__preview-body">
|
||||
{(() => {
|
||||
const url = resolveMediaAssetFileUrl(preview.id)
|
||||
const kind = previewDisplayKind(preview.mime_type)
|
||||
if (!url) {
|
||||
return <p className="media-library__hint">Keine Datei-URL.</p>
|
||||
}
|
||||
if (kind === 'image') {
|
||||
return (
|
||||
<img
|
||||
key={preview.id}
|
||||
className="media-library__preview-img"
|
||||
src={url}
|
||||
alt=""
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (kind === 'video') {
|
||||
return (
|
||||
<video
|
||||
key={preview.id}
|
||||
className="media-library__preview-video"
|
||||
src={url}
|
||||
controls
|
||||
playsInline
|
||||
preload="metadata"
|
||||
>
|
||||
Wiedergabe nicht unterstützt.
|
||||
</video>
|
||||
)
|
||||
}
|
||||
if (kind === 'pdf') {
|
||||
return (
|
||||
<div className="media-library__preview-fallback">
|
||||
<p className="media-library__hint">PDF — zur Ansicht im neuen Tab öffnen.</p>
|
||||
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
|
||||
PDF öffnen
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<div className="media-library__preview-fallback">
|
||||
<p className="media-library__hint">
|
||||
Vorschau für diesen Typ nicht verfügbar ({preview.mime_type || 'unbekannt'}).
|
||||
</p>
|
||||
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
|
||||
Datei öffnen
|
||||
</a>
|
||||
</div>
|
||||
)
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{bulkOpen ? (
|
||||
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="bulk-title">
|
||||
<div className="media-library__modal media-library__modal--wide">
|
||||
<div className="media-library__modal-head">
|
||||
<h2 id="bulk-title">Bulk für {selCount} Medien</h2>
|
||||
<button type="button" className="media-library__icon-btn" onClick={() => setBulkOpen(false)} aria-label="Schließen">
|
||||
<X size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="media-library__modal-body">
|
||||
<p className="media-library__hint">Nur ausgefüllte Felder werden gesetzt. Fehlende Rechte pro Medium werden im Ergebnis gemeldet.</p>
|
||||
<label className="form-label">Copyright (optional)</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={2}
|
||||
value={bulkCopyright}
|
||||
onChange={(e) => setBulkCopyright(e.target.value)}
|
||||
/>
|
||||
<label className="media-library__check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkApplyVis}
|
||||
onChange={(e) => setBulkApplyVis(e.target.checked)}
|
||||
/>
|
||||
<span>Sichtbarkeit ändern</span>
|
||||
</label>
|
||||
{bulkApplyVis ? (
|
||||
<>
|
||||
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{bulkVis === 'club' ? (
|
||||
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
|
||||
<option value="">— Verein —</option>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
<div className="media-library__modal-actions">
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={runBulkPatch}>
|
||||
Metadaten anwenden
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!window.confirm('Ausgewählte aktiven Medien in Papierkorb (1)?')) return
|
||||
runBulkLifecycle('trash_soft')
|
||||
}}
|
||||
>
|
||||
Papierkorb (1)
|
||||
</button>
|
||||
{isSuperadmin ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => {
|
||||
if (!window.confirm('Superadmin: Ausgewählte endgültig löschen?')) return
|
||||
runBulkLifecycle('superadmin_hard_delete')
|
||||
}}
|
||||
>
|
||||
Hard-Delete
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{modal && modalDraft ? (
|
||||
<div className="media-library__overlay" role="dialog" aria-modal="true" aria-labelledby="edit-media-title">
|
||||
<div className="media-library__modal">
|
||||
<div className="media-library__modal-head">
|
||||
<h2 id="edit-media-title">Medium #{modal.id}</h2>
|
||||
<button type="button" className="media-library__icon-btn" onClick={closeModal} aria-label="Schließen">
|
||||
<X size={22} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="media-library__modal-body">
|
||||
{(viewer?.show_club_meta || viewer?.show_uploader_meta) && (
|
||||
<div className="media-library__meta-block">
|
||||
{viewer?.show_club_meta ? (
|
||||
<div>
|
||||
<span className="media-library__meta-k">Verein</span>
|
||||
<span className="media-library__meta-v">{modal.club_name || modal.club_id || '—'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
{viewer?.show_uploader_meta ? (
|
||||
<div>
|
||||
<span className="media-library__meta-k">Uploader</span>
|
||||
<span className="media-library__meta-v">{uploaderLabel(modal, viewer) || '—'}</span>
|
||||
</div>
|
||||
) : null}
|
||||
<div>
|
||||
<span className="media-library__meta-k">Technisch</span>
|
||||
<span className="media-library__meta-v mono">
|
||||
{modal.mime_type || '—'} · ID {modal.id}
|
||||
{isSuperadmin && modal.storage_key ? ` · ${modal.storage_key}` : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{modal.permissions?.edit_metadata ? (
|
||||
<>
|
||||
<label className="form-label">Bezeichnung (Dateiname / Anzeige)</label>
|
||||
<input
|
||||
className="form-input"
|
||||
value={modalDraft.display_name}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, display_name: e.target.value }))}
|
||||
/>
|
||||
<label className="form-label">Copyright</label>
|
||||
<textarea
|
||||
className="form-input"
|
||||
rows={3}
|
||||
value={modalDraft.copyright_notice}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<p className="media-library__hint">Keine Berechtigung für Metadaten — nur Verwaltende dieser Stufe.</p>
|
||||
)}
|
||||
|
||||
{modal.permissions?.change_visibility ? (
|
||||
<>
|
||||
<label className="form-label">Sichtbarkeit</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={modalDraft.visibility}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
|
||||
>
|
||||
{VIS_OPTIONS.map((o) => (
|
||||
<option key={o.value} value={o.value}>
|
||||
{o.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{modalDraft.visibility === 'club' ? (
|
||||
<>
|
||||
<label className="form-label">Verein</label>
|
||||
<select
|
||||
className="form-input"
|
||||
value={modalDraft.club_id}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, club_id: e.target.value }))}
|
||||
>
|
||||
<option value="">— wählen —</option>
|
||||
{clubs.map((c) => (
|
||||
<option key={c.id} value={String(c.id)}>
|
||||
{c.name || c.id}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</>
|
||||
) : null}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div className="media-library__modal-actions">
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
|
||||
Speichern
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="media-library__lc-block">
|
||||
<div className="media-library__lc-title">Lebenszyklus</div>
|
||||
<div className="media-library__lc-btns">
|
||||
{modal.permissions?.trash_soft ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runLc(modal.id, 'trash_soft', 'In Papierkorb (Stufe 1) legen?')
|
||||
}
|
||||
>
|
||||
Papierkorb
|
||||
</button>
|
||||
) : null}
|
||||
{modal.permissions?.trash_hidden ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runLc(
|
||||
modal.id,
|
||||
'trash_hidden',
|
||||
'Ausblenden (Stufe 2)? Öffentliche Ansicht verliert das Medium.',
|
||||
)
|
||||
}
|
||||
>
|
||||
Ausblenden
|
||||
</button>
|
||||
) : null}
|
||||
{modal.permissions?.recover ? (
|
||||
<button type="button" className="btn btn-secondary" disabled={busy} onClick={() => runLc(modal.id, 'recover', null)}>
|
||||
↩ Stufe 1
|
||||
</button>
|
||||
) : null}
|
||||
{modal.permissions?.reactivate ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() => runLc(modal.id, 'reactivate', null)}
|
||||
>
|
||||
Wieder aktiv
|
||||
</button>
|
||||
) : null}
|
||||
{modal.permissions?.purge ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runLc(
|
||||
modal.id,
|
||||
'purge',
|
||||
'Endgültig löschen (nur Superadmin, Stufe 2)? Datei und DB-Eintrag.',
|
||||
)
|
||||
}
|
||||
>
|
||||
Endgültig löschen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{modal.permissions?.superadmin_lifecycle ? (
|
||||
<div className="media-library__lc-block media-library__lc-block--danger">
|
||||
<div className="media-library__lc-title">Superadmin</div>
|
||||
<div className="media-library__row">
|
||||
<select
|
||||
className="form-input"
|
||||
value={modalDraft.superTarget}
|
||||
onChange={(e) => setModalDraft((d) => ({ ...d, superTarget: e.target.value }))}
|
||||
>
|
||||
<option value="active">aktiv</option>
|
||||
<option value="trash_soft">Papierkorb 1</option>
|
||||
<option value="trash_hidden">ausgeblendet 2</option>
|
||||
</select>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runLc(
|
||||
modal.id,
|
||||
'superadmin_force_lifecycle',
|
||||
`Zustand erzwingen: ${modalDraft.superTarget}?`,
|
||||
{ target_lifecycle: modalDraft.superTarget },
|
||||
)
|
||||
}
|
||||
>
|
||||
Zustand setzen
|
||||
</button>
|
||||
</div>
|
||||
{modal.permissions?.superadmin_hard_delete ? (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
disabled={busy}
|
||||
onClick={() =>
|
||||
runLc(
|
||||
modal.id,
|
||||
'superadmin_hard_delete',
|
||||
'Medium komplett entfernen (DB + Datei)?',
|
||||
)
|
||||
}
|
||||
>
|
||||
Komplett löschen
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
@ -120,6 +120,18 @@ export async function listAdminUsers() {
|
|||
return request('/api/admin/users')
|
||||
}
|
||||
|
||||
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
|
||||
export async function getPlatformMediaStorage() {
|
||||
return request('/api/admin/platform-media-storage')
|
||||
}
|
||||
|
||||
export async function putPlatformMediaStorage(payload) {
|
||||
return request('/api/admin/platform-media-storage', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProfile(profileId, data) {
|
||||
return request(`/api/profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
|
|
@ -394,7 +406,7 @@ export async function listExercises(filters = {}) {
|
|||
}
|
||||
|
||||
/** Formular → API-Body (M:N gemäß EXERCISES_API_SPEC + training_types) */
|
||||
export function buildExerciseApiPayload(formData) {
|
||||
export function buildExerciseApiPayload(formData, extras = {}) {
|
||||
const num = (v) => (v === '' || v == null ? null : Number(v))
|
||||
|
||||
const goalHtml = formData.goal || ''
|
||||
|
|
@ -446,6 +458,7 @@ export function buildExerciseApiPayload(formData) {
|
|||
visibility: formData.visibility || 'private',
|
||||
status: formData.status || 'draft',
|
||||
club_id: formData.club_id ?? null,
|
||||
...extras,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -461,12 +474,29 @@ export async function uploadExerciseMedia(exerciseId, formData) {
|
|||
if (!response.ok) {
|
||||
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||
const d = err.detail
|
||||
if (
|
||||
response.status === 409 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(
|
||||
typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden',
|
||||
)
|
||||
e.code = d.code
|
||||
e.status = 409
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
const msg =
|
||||
typeof d === 'string'
|
||||
? d
|
||||
: d != null
|
||||
? JSON.stringify(d)
|
||||
: `HTTP ${response.status}`
|
||||
: d != null && typeof d === 'object' && typeof d.message === 'string'
|
||||
? d.message
|
||||
: d != null
|
||||
? JSON.stringify(d)
|
||||
: `HTTP ${response.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
return response.json()
|
||||
|
|
@ -490,6 +520,54 @@ export async function reorderExerciseMedia(exerciseId, mediaIds) {
|
|||
})
|
||||
}
|
||||
|
||||
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
|
||||
export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
|
||||
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action, ...extra }),
|
||||
})
|
||||
}
|
||||
|
||||
/** Archiv: aktive media_assets sichtbar für den Nutzer (Bibliotheksrechte). */
|
||||
export async function listMediaAssets(params = {}) {
|
||||
const sp = new URLSearchParams()
|
||||
if (params.q) sp.set('q', params.q)
|
||||
if (params.limit != null) sp.set('limit', String(params.limit))
|
||||
if (params.offset != null) sp.set('offset', String(params.offset))
|
||||
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
|
||||
const qs = sp.toString()
|
||||
return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
|
||||
}
|
||||
|
||||
export async function patchMediaAsset(assetId, data) {
|
||||
return request(`/api/media-assets/${assetId}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkMediaLifecycle(data) {
|
||||
return request('/api/media-assets/bulk-lifecycle', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
export async function bulkPatchMediaAssets(data) {
|
||||
return request('/api/media-assets/bulk-patch', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
}
|
||||
|
||||
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
|
||||
export async function attachExerciseMediaFromAsset(exerciseId, body) {
|
||||
return request(`/api/exercises/${exerciseId}/media/from-asset`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
export async function getExercise(id) {
|
||||
return request(`/api/exercises/${id}`)
|
||||
}
|
||||
|
|
@ -502,10 +580,45 @@ export async function createExercise(data) {
|
|||
}
|
||||
|
||||
export async function updateExercise(id, data) {
|
||||
return request(`/api/exercises/${id}`, {
|
||||
const token = localStorage.getItem('authToken')
|
||||
const headers = mergeActiveClubHeader({ 'Content-Type': 'application/json' })
|
||||
if (token) headers['X-Auth-Token'] = token
|
||||
const url = `${API_URL}/api/exercises/${id}`
|
||||
const response = await fetch(url, {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(data)
|
||||
headers,
|
||||
body: JSON.stringify(data),
|
||||
})
|
||||
if (!response.ok) {
|
||||
const text = await response.text()
|
||||
let parsed = null
|
||||
try {
|
||||
parsed = JSON.parse(text)
|
||||
} catch {
|
||||
parsed = null
|
||||
}
|
||||
const d = parsed?.detail
|
||||
if (
|
||||
response.status === 422 &&
|
||||
d &&
|
||||
typeof d === 'object' &&
|
||||
!Array.isArray(d) &&
|
||||
typeof d.code === 'string'
|
||||
) {
|
||||
const e = new Error(typeof d.message === 'string' ? d.message : 'Validierung fehlgeschlagen')
|
||||
e.status = 422
|
||||
e.code = d.code
|
||||
e.payload = d
|
||||
throw e
|
||||
}
|
||||
if (parsed?.detail != null) {
|
||||
const msg = typeof parsed.detail === 'string' ? parsed.detail : JSON.stringify(parsed.detail)
|
||||
throw new Error(msg)
|
||||
}
|
||||
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
|
||||
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
|
||||
}
|
||||
return response.json()
|
||||
}
|
||||
|
||||
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
|
||||
|
|
@ -1191,6 +1304,12 @@ export const api = {
|
|||
updateExerciseMedia,
|
||||
deleteExerciseMedia,
|
||||
reorderExerciseMedia,
|
||||
postMediaAssetLifecycle,
|
||||
listMediaAssets,
|
||||
patchMediaAsset,
|
||||
bulkMediaLifecycle,
|
||||
bulkPatchMediaAssets,
|
||||
attachExerciseMediaFromAsset,
|
||||
listExerciseProgressionGraphs,
|
||||
getExerciseProgressionGraph,
|
||||
createExerciseProgressionGraph,
|
||||
|
|
|
|||
31
frontend/src/utils/exerciseMediaUrl.js
Normal file
31
frontend/src/utils/exerciseMediaUrl.js
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
/** URL für Übungs-Mediendateien: API mit Token (Legacy /media/ ohne Auth ist abgeschaltet). */
|
||||
|
||||
const API_BASE = (import.meta.env.VITE_API_URL || '').replace(/\/$/, '')
|
||||
|
||||
/**
|
||||
* @param {number|string} exerciseId
|
||||
* @param {object} media — exercise_media Zeile mit id, file_path
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveExerciseMediaFileUrl(exerciseId, media) {
|
||||
if (!media?.file_path) return null
|
||||
const fp = String(media.file_path)
|
||||
if (fp.startsWith('http://') || fp.startsWith('https://')) return fp
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
|
||||
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
|
||||
const id = media.id
|
||||
if (id == null || exerciseId == null) return null
|
||||
return `${API_BASE}/api/exercises/${exerciseId}/media/${id}/file${q}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Direkt-URL für Archiv-Asset (Picker/Vorschau ohne exercise_media-Zeile).
|
||||
* @param {number|string} assetId — media_assets.id
|
||||
* @returns {string|null}
|
||||
*/
|
||||
export function resolveMediaAssetFileUrl(assetId) {
|
||||
if (assetId == null) return null
|
||||
const token = typeof localStorage !== 'undefined' ? localStorage.getItem('authToken') : ''
|
||||
const q = token ? `?ssetoken=${encodeURIComponent(token)}` : ''
|
||||
return `${API_BASE}/api/media-assets/${assetId}/file${q}`
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user