MediaPfad extern, Upload Manager Bug Fixes #23

Merged
Lars merged 21 commits from develop into main 2026-05-08 11:17:12 +02:00
38 changed files with 1868 additions and 275 deletions

View File

@ -1,29 +1,29 @@
# Shinkan Jinkendo - Projekt-Status
**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`)
**Version (Code):** 0.8.59 (`backend/version.py`, APP_VERSION)
**DB-Schema-Version:** `20260508049` (`backend/version.py`, DB_SCHEMA_VERSION)
**Branch:** develop
---
## Executive Summary
**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).
**Aktueller Meilenstein (Medien):** Das **Medien-Archiv** (`media_assets` + `exercise_media.media_asset_id`) ist **produktiv nutzbar**: zentrale Bibliothek **`/media`** (Kacheln/Liste, Filter inkl. Lifecycle, Suche/Tags, Copyright, Bulk-Lifecycle und Bulk-PATCH), **Verknüpfung aus dem Archiv** in der Übungsbearbeitung (`POST …/media/from-asset`), **deduplizierter Speicher** unter **`library/…`** (Vereinsordner aus Name, Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeitsänderung), **Papierkorb & Lifecycle** (Reaktivierung, Soft-Trash, Superadmin-Purge). **Governance:** Sichtbarkeit **`official`** nur noch **Superadmin** (Übungen und Medien); Plattform-Admin wie Trainer für Vereins-/Private-Inhalte. **Vereinsübungen** mit Datei-Assets: **Copyright-Pflicht** (API/UI). **Aktiver Verein:** Dropdown, Profilfeld `active_club_id`, Header `X-Active-Club-Id` und `effective_club_id` sind nach **0.8.59** synchronisiert (inkl. Plattform-Admin ohne Header beim ersten Request).
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036037), **Progressionsgraph** (032034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
**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) §12 · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) (inkl. **§11 Inline-Medien**, Planung)
**Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
**Nächste Schritte — Medien & Archiv** (Stand 2026-05-07, für **neue Session**):
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.
2. ~~**Eigenständige Medienmanager-Seite**~~**Basis umgesetzt** (`/media`); Ausbau nach Bedarf: Quotas, feinere Bulk-Workflows, Sichtbarkeits-PATCH in der UI vereinheitlichen.
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen/Lifecycle; Retention-Job (`scripts/media_retention_job.py`) in Betrieb/Doku verankern.
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
5. **Inline-Medien im Fließtext** (Spec **§11**) — nächste inhaltliche Ausbaustufe: Platzhalter-Syntax, ein zentraler Renderer, keine zweite Governance; Archiv-Basis ist gelegt.
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; kein Big-Bang vor stabiler Archiv-/Governance-Basis.
**Inline:** verbindliche Leitplanken **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; Umsetzung bewusst nach stabiler Archiv-/Governance-Basis.
---
@ -50,7 +50,7 @@
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
| **032034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
| **035037** | **Rahmenprogramm, BibliothekKopf, SlotBlueprintUnits** | ✅ | 🔲 |
| **040045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
| **040046** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets` (046 z.B. Tags/GIN), Plattform-Speicherpfad** | ✅ Dev | 🔲 Prod |
---
@ -103,10 +103,11 @@ 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
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (0.8.42 ff.)
- [x] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` / Vereins-Copyright-Regeln (Spec §4.2, Übungen+Assets)
- [x] **Medien:** Medienbibliothek `/media` (Filter, Tags, Copyright, Bulk-Lifecycle/PATCH, Lesemodus für eingeschränkte Rollen bei `official`)
- [x] **Medien:** Speicherpfad-Konvention `library/…`, Plattform-Speicher-Konfiguration (`platform_media_storage`), Mandanten-/Governance-Umzug bei Asset-Änderungen
- [ ] **Medien:** Inline im Fließtext — Spec §11 (nächste Session / Backlog)
- [ ] Admin-UI für Skill-Kategorien (CRUD) falls noch offen
- [ ] Responsive Design / Dark Mode / PWA
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
@ -137,7 +138,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
### Dev
Branch `develop`; Migrations bis mindestens **045** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
Branch `develop`; Migrations bis mindestens **046** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
### Prod
@ -149,17 +150,17 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
| Dokument | Pfad | Stand | Status |
|----------|------|-------|--------|
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-05 | ✅ Aktualisiert (u. a. 036037) |
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-07 | ✅ Aktualisiert (§12 Medien 0.8.410.8.59) |
| Trainingsrahmen + Graph | `technical/TRAINING_FRAMEWORK_SPEC.md` | 2026-05-05 | ✅ §2 Blueprint |
| Anforderungen (Index) | `functional/SHINKAN_REQUIREMENTS.md` | 2026-04-27 | ✅ Neu |
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-05 | ✅ Aktualisiert (037) |
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-05 | ✅ Aktualisiert |
| Database Schema | `technical/DATABASE_SCHEMA.md` | 2026-05-07 | ✅ Hinweis 040046 Medien (Kurz) |
| Domain Model | `functional/DOMAIN_MODEL.md` | 2026-05-07 | ✅ Abschnitt Medien-Archiv |
| API Übungen | `technical/EXERCISES_API_SPEC.md` | 2026-04-30 | ✅ Ergänzt Progressions-API |
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ §11 Inline-Plan, Drift-Tab |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Medien-Meilenstein aktualisiert |
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-05-07 | ✅ Verweis Archiv/Inline |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ Ist-Changelog + §11 Inline geplant |
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Medien + Tenant-Dropdown |
---
@ -170,4 +171,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
---
**Letzte Aktualisierung:** 2026-05-07
**Letzte Aktualisierung:** 2026-05-07 (Medien-Doku abgestimmt, §12 FEATURES, Handover)

View File

@ -1,7 +1,7 @@
# Shinkan Jinkendo - Fachliches Domänenmodell
**Version:** 0.4.3
**Stand:** 2026-05-05 (Migration **036037:** Rahmen nur Bibliothek; SlotInhalt über Blueprint`training_units` + Sektionen/Items wie Planung — siehe `TRAINING_FRAMEWORK_SPEC.md` §2)
**Version:** 0.4.4
**Stand:** 2026-05-07 (Medien-Archiv **`media_assets`** / Bibliothek **`/media`** im Ist; **Inline-Medien** Fließtext geplant — `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11)
**Basis:** `shinkan_anforderungsdokument_entwurf.md` + Fähigkeitsmatrix
---
@ -476,6 +476,16 @@ skill_level_definitions (
---
## Medien-Archiv & Übungs-Anhänge (Stand 2026-05-07)
- **`media_assets`:** Zentrale Datei-/Asset-Zeile (technisch u.a. SHADedupe, Sichtbarkeit, `club_id`, Lifecycle, Copyright, Speicherreferenz unter `library/…`). Siehe **`DATABASE_SCHEMA.md`**, **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
- **`exercise_media`:** Verknüpfung **Übung ↔ Asset** (`media_asset_id`) oder **Embed** ohne Asset; Felder wie `context` (`ablauf` \| `detail` \| `trainer_hint`), Sortierung, Primär-Medium.
- **`platform_media_storage`:** Konfiguration effektiver Medienwurzel (Superadmin, relativ zu `MEDIA_ROOT`).
- **Produkt:** Medienbibliothek **`/media`**; in der Übungsbearbeitung Upload, Entfernen der Verknüpfung, **Aus Archiv verknüpfen**; Governance **`official`** und Copyright-Regeln wie in der Norm beschrieben.
- **Geplant:** **Inline-Verweise** in Fließtextfeldern auf dieselbe Verknüpfung (`exercise_media.id`) — **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**, **`docs/HANDOVER.md`** §5.
---
## Methodenbezug (§11.5)
**Prinzip:** Genau EINE Hauptmethode, optional weitere Nebenmethoden.

View File

@ -5,9 +5,10 @@ Ausführliche fachliche Inhalte:
| Dokument | Inhalt |
|----------|--------|
| [shinkan_anforderungsdokument_entwurf.md](./shinkan_anforderungsdokument_entwurf.md) | Gesamtentwurf Anforderungen |
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (Abschnitt 11.2) |
| [DOMAIN_MODEL.md](./DOMAIN_MODEL.md) | Domänenmodell, Variantenlogik (§11.2), **Medien-Archiv** (Abschnitt 2026-05) |
| [MEDIA_ASSETS_AND_ARCHIVE_SPEC.md](../technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md) | Medien-Archiv, Lifecycle, **geplante Inline-Medien §11** |
| [MULTI_TENANCY_RBAC_ARCHITECTURE.md](../technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md) | Zielarchitektur Mandanten/Rollen/Membership & Umsetzungsplan |
**Lieferstand & Umsetzung (Stand Code):** siehe [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md) und [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md).
**Lieferstand & Umsetzung (Stand Code):** [`../PROJECT_STATUS.md`](../PROJECT_STATUS.md), [`../library/FEATURES_DELIVERED_2026-Q2.md`](../library/FEATURES_DELIVERED_2026-Q2.md) §12, Repo-Root **`docs/HANDOVER.md`**.
`CLAUDE.md` (Repo-Root) verweist hierher als Einstieg.

View File

@ -1,9 +1,9 @@
# Gelieferte Features & technische Basis (Q2 2026)
**Stand:** 2026-05-05
**Referenz:** `backend/version.py`**APP_VERSION 0.8.10**, **DB_SCHEMA_VERSION 20260505037**
**Stand:** 2026-05-07
**Referenz:** `backend/version.py`**APP_VERSION 0.8.59**, **DB_SCHEMA_VERSION** siehe dort
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. TrainingsrahmenBibliothek + SlotBlueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§34**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren** Funktionen und die zugehörigen **technischen Artefakte**. TrainingsrahmenBibliothek + SlotBlueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§34**. **Medien-Archiv & Bibliothek:** Abschnitt **12** unten + **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Detail-Spezifikationen bleiben in den verlinkten Pfaden unter `.claude/docs/technical/` und `.claude/docs/functional/`.
---
@ -17,7 +17,7 @@ Dieses Dokument bündelt die in der Entwicklungsphase erreichten **lieferbaren**
| **030** | `training_unit_exercises.exercise_variant_id` → FK `exercise_variants(id)` ON DELETE SET NULL |
| **035** | **`training_framework_programs`** + Ziele, Slots (+ frühere SlotÜbungstabelle, heute entfallen nach **037**); **`training_plan_templates.visibility`** |
| **036** | Rahmen nur Bibliothek: Kontext + M:N Trainingsarten/Zielgruppen; keine Modus-Spalten / keine Kopf`group_id` |
| **037** | **`training_units.framework_slot_id`**, strukturierter Ablauf wie Planung; Entfall **`training_framework_slot_exercises`**; **`origin_framework_slot_id`** |
| **045046** | **`media_assets`**, `platform_media_storage`, `exercise_media.media_asset_id`, ggf. **Tags/GIN** — siehe `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
---
@ -79,7 +79,7 @@ Logik: `_upload_limit_bytes(session)` vor `read()`-Prüfung.
## 6. Frontend Übung bearbeiten (`ExerciseFormPage.jsx`)
- **Varianten-Editor**: eingeklappter Bereich (`<details>`), **eine Variante zur Zeit** über Dropdown oder „Neue Variante“; Felder über **`ExerciseVariantFields`**; Reihenfolge Nach oben/unten; Speichern/Löschen pro Variante.
- **Medien** wie zuvor (Formularteil).
- **Medien:** Upload/Embed, **Archiv verknüpfen** (`from-asset`), Medienliste mit Vorschau, Reaktivierung bei Archiv-Konflikt — Details **§12**.
- Block **Progressionsgraph** (Edit): Kanten mit Bezug zur aktuellen Übung.
Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Bearbeitung erfolgt unter **`/exercises/:id/edit`** (Routing-Doku ggf. anpassen).
@ -123,16 +123,40 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
---
## 12. Nächste sinnvolle Schritte (nicht Lieferstand)
## 12. Medien-Archiv & Medienbibliothek (Migration **045** ff., App ca. **0.8.410.8.59**)
Einzelnorm: **`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. Kurzüberblick geliefert:
### 12.1 Datenbank & Router
- **`media_assets`**, **`platform_media_storage`**, **`exercise_media.media_asset_id`** (Dedupe `sha256` + Sichtbarkeit + `club_id` je nach Policy).
- Router **`media_assets.py`**: Listen, PATCH, Lifecycle, Dateiauslieferung, Bulk; Integration in **`exercises.py`** (Upload, `from-asset`, Promotion/Copyright-Regeln bei `official` / Vereinsübungen).
### 12.2 Funktional (Ist)
- Zentrale **Medienbibliothek** Frontend **`/media`**: Filter (Lifecycle, Medientyp, Verein für Admins), Suche, Tags, Copyright bearbeiten (rollengerecht), Kacheln/Liste, Nutzungs-Hinweise (Übungen/Planung wo implementiert).
- **Papierkorb** mehrstufig (`trash_soft` / `trash_hidden`), Recovery, Superadmin-Purge; strukturierte Fehlercodes bei Governance (u.a. Übung **`CLUB_MEDIA_*`**).
- **Speicher:** Pfadkonvention **`library/{vereinssegment}/…`** mit Medienkind-Unterordnern; Umzug bei Sichtbarkeits-/Vereinsänderung; effektives Wurzelverzeichnis `MEDIA_ROOT` + Superadmin-`local_relative_root`.
- **Governance:** **`visibility=official`** für Übungen und schützenswerte Medien-Operationen im Wesentlichen **Superadmin**; Plattform-Admin entspricht nicht automatisch Superadmin.
- **Mandant:** aktiver Verein über Profil + **`X-Active-Club-Id`**; Sync UI nach **0.8.59** (siehe `tenant_context` / `AuthContext` / `activeClub.js`).
### 12.3 Geplant (nicht geliefert)
- **Inline-Medien** in Fließtextfeldern gemäß **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** — Verweis auf `exercise_media.id`, zentraler Renderer; siehe **`docs/HANDOVER.md` §5**.
---
## 13. Nächste sinnvolle Schritte (nicht Lieferstand)
- Trainingsplanung: KalenderUIAnbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR004** später).
- Progressions-Serien als **Blöcke** (angekündigt; Voraussetzung: `prerequisite_variant_id` / `progression_level` vorhanden).
- Serverseitige **Suchvorschläge** (Autocomplete-Endpoint), falls datalist nicht reicht.
- Optional: Streaming/chunked Upload für sehr große Videos (RAM-Thema).
- **Medien:** Inline Fließtext §11; Retention-Job-Betrieb; pytest-Abdeckung Archiv; S3-Adapter (Spec §7).
---
## 13. Verweise
## 14. Verweise
| Thema | Dokument |
|--------|----------|
@ -140,5 +164,6 @@ Hinweis: Es gibt **keine** separaten Routen `/exercises/:id/variants/...` — Be
| API Übungen | `technical/EXERCISES_API_SPEC.md` |
| Domänenmodell | `functional/DOMAIN_MODEL.md` |
| Datenbank Überblick | `technical/DATABASE_SCHEMA.md` |
| Upload formal | `technical/MEDIA_UPLOAD_SPEC.md` |
| Medien Upload (Limits, MIME) | `technical/MEDIA_UPLOAD_SPEC.md` |
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
| Projektstatus-Kachel | `../PROJECT_STATUS.md` |

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo - Datenbank-Schema (Technisch)
**Version:** 0.5.2
**Stand:** 2026-05-05
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **037** (RahmenSlotBlueprints in `training_units`; Entfall `training_framework_slot_exercises`) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`).
**Version:** 0.5.3
**Stand:** 2026-05-07
**Hinweis:** Produktiver Deploy sollte mindestens bis Migration **037** (RahmenSlotBlueprints) und für Medien-Archiv bis **045+** (`media_assets`, …) geführt sein — Details siehe `backend/version.py` (`DB_SCHEMA_VERSION`) und **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
---
@ -47,8 +47,7 @@ Dieses Dokument beschreibt die **technische Datenbankstruktur** von Shinkan Jink
| **035** | **2026-05-05** | **Rahmenprogramm:** `training_framework_programs` (+ Ziele, Slots, früher `training_framework_slot_exercises`); **`training_plan_templates.visibility`** (Backfill `club`) — siehe `TRAINING_FRAMEWORK_SPEC.md` | ✅ |
| **036** | **2026-05-05** | **Rahmen nur Bibliothek:** Kopf mit `focus_area_id`, `style_direction_id`, M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`, `group_id`; Slot`training_unit_id` geleert — siehe `036_framework_program_context_only_library.sql` | ✅ |
| **037** | **2026-05-05** | **SlotBlueprint:** `training_units.framework_slot_id` (+ CHECK Blueprint vs. Kalender), `origin_framework_slot_id`; Migration SlotÜbungen → `training_unit_sections`/`training_unit_section_items`; **`DROP training_framework_slot_exercises`** | ✅ |
---
| **040046** | **2026-05** | **Mitgliedschaft/Anträge, Übungs-Governance-Erweiterungen, `media_assets`, `platform_media_storage`, `exercise_media.media_asset_id`, Tags/GIN u.a.** — exakte Nummern: `backend/migrations/`; fachliche Norm Medien: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** | ✅ je Umgebung |
## Migration 022: Skills Schema Complete (BREAKING CHANGE)
@ -267,9 +266,11 @@ exercise_skills (
UNIQUE (exercise_id, skill_id) -- Migration 020
)
-- Varianten & Medien
-- Varianten & Medien (028+ Embed/Datei; 045+ optional media_asset_id → media_assets)
exercise_variants (id, exercise_id, name, description, ...)
exercise_media (id, exercise_id, type, url, title, description, ...)
exercise_media (id, exercise_id, media_asset_id NULL FK, embed_url, file_path, …)
media_assets (id, sha256, visibility, club_id, lifecycle_state, copyright_notice, storage_key, …)
platform_media_storage (id, local_relative_root, …)
```
### Trainingsrahmenprogramm Bibliothek (Migrationen **035036**)

View File

@ -1,7 +1,7 @@
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien
**Stand:** 2026-05-07 (§11 Inline-Plan ergänzt)
**Stand:** 2026-05-07 (Ist-Changelog Medien bis 0.8.59; §11 Inline geplant)
**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.
@ -186,8 +186,12 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
| Datum | Änderung |
|-------|----------|
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. |
| 2026-05-07 | §11 **Inline-Medien im Fließtext**: Leitplanken (Anker `exercise_media.id`, einheitlicher Render-Pfad, keine zweite Governance); Zeitpunkt der Umsetzung; Drift-Vermeidung ohne jetzigen Vollbau. |
| 2026-05-07 | **0.8.59:** Dokumentation — Aktiver Verein (Profil/Header/`effective_club_id`) für Plattform-Admin und UI-Dropdown synchron; kein fachliches Archiv-Schema-Change. |
| 2026-05-07 | **0.8.58:** Medien **`official`:** Lifecycle schwerpunktmäßig **Superadmin** (nicht Plattform-Admin); Bearbeitungsdialog Bibliothek für andere Rollen **Lesemodus**; Superadmin-Upload: Vereinskontext folgt aktiv gesetztem Verein / `effective_club_id`. |
| 2026-05-07 | **0.8.470.8.57 (Auszug):** Übung **`official`** nur Superadmin; Vereinsübungen mit File-Assets: **Copyright-Pflicht**; Speicherpfade **`library/`** mit Vereinsordner (Name+c{id}), Medienkind-Unterordner, Governance-Umzug bei Sichtbarkeit; Bibliothek-GET mit Filtern/Tags/Nutzungs-Anzeige; Bulk-Lifecycle/PATCH; Lesemodus/Kacheln; Konflikt **409** bei Upload-Dedupe vs. Papierkorb + UI-Reaktivierung. |
| 2026-05-07 | **0.8.420.8.43:** Papierkorb-Lifecycle API, Retention-Skript; **`POST …/exercises/{id}/media/from-asset`**; Übungs-UI Archiv verknüpfen / Vorschau. |
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung 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); Umsetzung **nach** tragfähigem Archiv — siehe **`docs/HANDOVER.md`** §5. |
| 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). |
---
@ -219,9 +223,9 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
### 11.5 Wann umsetzen (Reihenfolge)
1. **Vorher:** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Basis-Papierkorb (§5) jeweils stabil.
2. **Danach:** Inline implementieren, sobald Trainer-Feedback oder Content-Menge den Bedarf **konkret** bestätigt (typisch nach 12 Beta-Zyklen).
3. **Nicht nötig:** vorher kompletten Block-Editor einführen; **Platzhalter im bestehenden RTE** ist der vorgesehene **schlanke** Einstieg.
1. ~~**Erledigt (Basis):** Medien-Archiv, `media_assets`, Upload/Dedupe, Speicherpfad, Papierkorb, Bibliothek `/media`, Verknüpfung `from-asset`, Governance `official`/Copyright.~~
2. **Als Nächstes (geplant):** Inline implementieren gemäß §11.111.4 — Trainer-Feedback/Content-Menge kann Priorität schärfen; technische Leitplanken hier sind verbindlich.
3. **Editor:** kein Zwang zum vollen Block-Editor vorab; **Platzhalter im bestehenden RTE** ist der vorgesehene schlanke Einstieg.
### 11.6 Refactor-Vermeidung (jetzt schon)

View File

@ -1,10 +1,8 @@
# Media Upload & Embed Specification
**Version:** 1.1
**Datum:** 2026-04-27
**Status:** DRAFT - Awaiting Review
**Autor:** Claude Code
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
**Version:** 1.2
**Datum:** 2026-05-07
**Status:** Aktuell für Upload-Limits, MIME, Embed — **zentraler Medien-Ist-Stand** inkl. Archiv, Lifecycle, Pfadkonvention: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**
> **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).

View File

@ -1,11 +1,14 @@
# Session Handover (Verweis)
**Dieses Dokument ist veraltet.** Der aktuelle Entwicklungsstand und die Handover-Basis stehen hier:
**Aktuelle Handover-Basis (Stand Implementierung + Medien):**
👉 **`docs/HANDOVER.md`** (Projektroot: `c:\Dev\shinkan-jinkendo\docs\HANDOVER.md`)
👉 **`docs/HANDOVER.md`** (Projektroot)
Dort: Fähigkeits-/Reifegrad-Stand (Bindings, Resolve, Export/Import, Matrix-Stack), Verweise auf `.claude/docs` für Anforderungen, und **nächste Priorität Übungen (UI/CRUD/Medien)**.
Dort: **Medien-Archiv/Medienbibliothek (Ist)**, **geplante Inline-Medienverlinkung (§11 Spec)**, Reifegrad/Matrix, Rahmenprogramm, nächste sinnvolle Arbeitspakete für neue Sessions.
**Projektstatus-Kachel:** `.claude/docs/PROJECT_STATUS.md`
**Medien Single Source of Truth:** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
---
*Historischer Inhalt aus April 2026 wurde durch `docs/HANDOVER.md` ersetzt.*
*Die historische „Übungen Lücke“-Fassung (2026-04) ist überholt; Medien-UI und Archiv sind seit 0.8.41+ weitgehend umgesetzt — Details `docs/HANDOVER.md` §4.*

View File

@ -58,7 +58,8 @@ 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`
**Medien-Assets (Archiv, Papierkorb, Promotion, Copyright, externer Speicher, geplante Inline-Verlinkung §11):** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
**Session-Handover (Ist + nächste Schritte):** `docs/HANDOVER.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:

View File

@ -10,7 +10,8 @@
1. Repo-Root: `CLAUDE.md` (Kontext, Links, Pflicht-Dokus)
2. Agent-Übersicht: **`.claude/README.md`** (Baum, wo was liegt)
3. Spez-Index: **`.claude/docs/README.md`**
4. Aufgaben-Tracking: **Gitea** Übersicht lokal: **`.claude/docs/GITEA_ISSUES_INDEX.md`** (regelmäßig refreshen nach Bedarf)
4. **Session-Handover (aktuelle Prioritäten, Medien-Ist):** **`docs/HANDOVER.md`**
5. Aufgaben-Tracking: **Gitea** Übersicht lokal: **`.claude/docs/GITEA_ISSUES_INDEX.md`** (regelmäßig refreshen nach Bedarf)
---

View File

@ -12,6 +12,8 @@
# APP_URL=https://shinkan.jinkendo.de
# ALLOWED_ORIGINS=https://shinkan.jinkendo.de
# ENVIRONMENT=production
# SHINKAN_MEDIA_HOST=/shinkan-media
# MEDIA_ROOT=/app/media
# ─── Typische Werte DEV (docker-compose.dev-env.yml) ─────────────────────────
# DB_NAME=shinkan_dev
@ -20,6 +22,8 @@
# APP_URL=https://dev.shinkan.jinkendo.de
# ALLOWED_ORIGINS=https://dev.shinkan.jinkendo.de,http://192.168.2.49:3098
# ENVIRONMENT=development
# SHINKAN_MEDIA_HOST=/shinkan-media/dev
# MEDIA_ROOT=/app/media
# ─── Ab hier: eine ausfüllbare Vorlage (bei uns meist Prod-Defaults) ───────────
DB_HOST=postgres
@ -46,7 +50,10 @@ APP_URL=https://shinkan.jinkendo.de
ALLOWED_ORIGINS=https://shinkan.jinkendo.de
ENVIRONMENT=production
MEDIA_DIR=/app/media
# Medien (Docker Compose): SHINKAN_MEDIA_HOST = Verzeichnis auf dem Host (Bind-Mount),
# MEDIA_ROOT = gleicher Pfad im Container (muss mit dem Mount-Ziel übereinstimmen — FastAPI).
SHINKAN_MEDIA_HOST=/shinkan-media
MEDIA_ROOT=/app/media
MEDIAWIKI_API_URL=https://karatetrainer.net/api.php
MEDIAWIKI_USER=Jinkendo

View File

@ -11,7 +11,8 @@
> | 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` |
> | Medien-Archiv, Lifecycle, Inline (Plan §11) | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
> | Handover / nächste Session | **`docs/HANDOVER.md`** |
## Projekt-Übersicht
@ -57,7 +58,7 @@ backend/
└── routers/ # Router-Module
auth · profiles · clubs · groups · skills · methods
exercises · exercise_progression_graphs · training_units · training_programs
planning · import_wiki · admin · membership
planning · import_wiki · admin · membership · media_assets
frontend/src/
├── App.jsx # Root, Auth-Gates, Navigation
@ -82,10 +83,11 @@ frontend/src/
**Siehe:** `backend/version.py` (`APP_VERSION`, `DB_SCHEMA_VERSION`, `MODULE_VERSIONS`) und `.claude/docs/PROJECT_STATUS.md`.
Kurz (Stand 2026-05-05): App **0.8.10**, DBSchemaVersion **`20260505037`**; Kern: Übungen, Varianten, Medien, Planung mit Sektionen, **Trainingsrahmen Bibliothek + SlotBlueprint** (036037), Progressionsgraph, Reifegrad/MatrixStack — Details `PROJECT_STATUS.md` und `TRAINING_FRAMEWORK_SPEC.md` §2.
Kurz (Stand 2026-05-07): App **0.8.59**, DBSchemaVersion siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + SlotBlueprint** (036037), Progressionsgraph, Reifegrad/MatrixStack — Details `PROJECT_STATUS.md`, `docs/HANDOVER.md`, `MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` (§11 **Inline geplant**).
### Log (Auszug)
- 2026-05-07: **Medien** — zentrales Archiv (`media_assets`), Bibliothek-UI, Lifecycle/Papierkorb, `from-asset`, Speicherpfade `library/…`, Governance `official`/Copyright; **0.8.59** aktiver Verein UI/API-Sync — siehe `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12, `docs/HANDOVER.md`.
- 2026-05-05: Rahmen nur Bibliothek (**036**), SlotAblauf = `training_units` + Sektionen (**037**), `POST /api/training-units/from-framework-slot`, keine `training_framework_slot_exercises` mehr — siehe `DATABASE_SCHEMA.md` / `FEATURES_DELIVERED_2026-Q2.md`.
- 2026-04-27: Übungsvarianten API/UI, Migration 030, Listen-UX-Suche, Admin-Upload-Limits — siehe `PROJECT_STATUS.md` und `docs/library/FEATURES_DELIVERED_2026-Q2.md`.

View File

@ -13,6 +13,7 @@ Shinkan ist eine moderne Web- und Mobile-App für Kampfsport-Trainer und Vereine
- **Kataloge:** Fähigkeiten und Trainingsmethoden strukturiert verwalten
- **Standardisierung:** Vereinsstandards und wiederverwendbare Vorlagen
- **Freigabe:** Gesteuerte Veröffentlichung von Inhalten
- **Medien:** Zentrale **Medienbibliothek** (`/media`), Archiv mit Lifecycle/Papierkorb, Verknüpfung in Übungen — Norm: `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` · Einstieg: `docs/HANDOVER.md`
## Nicht in Shinkan

View File

@ -157,13 +157,13 @@ def assert_valid_governance_visibility(
visibility: str,
club_id: Optional[int],
) -> None:
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Plattform-Admin."""
"""Pflicht club_id bei visibility=club; Mitgliedschaft außer Plattform-Admin; official nur Superadmin."""
if visibility not in _GOVERNANCE_VISIBILITY:
raise HTTPException(status_code=400, detail="Ungültige visibility")
if visibility == "official" and not is_platform_admin(role):
if visibility == "official" and not is_superadmin(role):
raise HTTPException(
status_code=403,
detail="Nur Plattform-Admins dürfen offizielle Inhalte setzen",
detail="Nur Superadmins dürfen offizielle Inhalte setzen",
)
if visibility == "club":
if club_id is None:

View File

@ -27,20 +27,26 @@ HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS",
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.
§5.2: official nur Superadmin; club Vereinsorga; privat nur Uploader (Plattform-Admin sonst wie bisher).
"""
profile_id = tenant.profile_id
role = (tenant.global_role or "").strip().lower()
vis = (asset.get("visibility") or "private").strip().lower()
if vis == "official":
if not is_superadmin(role):
raise HTTPException(
status_code=403,
detail="Offizielle Medien dürfen nur von Superadmins geändert oder gelöscht werden",
)
return
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:
@ -54,15 +60,20 @@ def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict)
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.
Vereinsmedien: Vereinsorga; official: nur Superadmin; Plattform-Admin: sonst wie Verein/privat.
"""
role_raw = tenant.global_role
role = (role_raw or "").strip().lower()
if is_superadmin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
if vis == "official":
raise HTTPException(
status_code=403,
detail="Offizielle Medien dürfen nur von Superadmins in den Papierkorb gelegt werden",
)
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)
@ -80,8 +91,6 @@ def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None:
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")

View File

@ -3,9 +3,23 @@ from __future__ import annotations
import os
import re
import shutil
import unicodedata
from pathlib import Path
from typing import Any, Optional
_CLUB_SLUG_TRANS = str.maketrans(
{
"ä": "ae",
"ö": "oe",
"ü": "ue",
"ß": "ss",
"Ä": "ae",
"Ö": "oe",
"Ü": "ue",
}
)
def _default_media_root() -> Path:
return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")))
@ -44,6 +58,70 @@ def get_effective_media_root(cur: Any) -> Path:
return (base / rel).resolve()
def library_media_kind_dir(mime_type: Optional[str], ext: str) -> str:
"""
Unterordner unter library/* an API-Filter media_kind angelehnt (image/video/pdf/other).
Bei fehlendem MIME wird anhand der Dateiendung geraten (letzte Instanz: other).
"""
m = (mime_type or "").strip().lower()
x = (ext or "").strip().lower()
if x and not x.startswith("."):
x = "." + x
if m.startswith("image/"):
return "image"
if m.startswith("video/"):
return "video"
if m == "application/pdf" or (m and "pdf" in m):
return "pdf"
if x in (
".jpg",
".jpeg",
".png",
".gif",
".webp",
".heic",
".heif",
".bmp",
".svg",
".avif",
):
return "image"
if x in (".mp4", ".mov", ".webm", ".mkv", ".avi", ".m4v"):
return "video"
if x == ".pdf":
return "pdf"
return "other"
def library_club_path_segment(club_id: int, club_name: Optional[str]) -> str:
"""
Ein Verzeichnissegment pro Verein: aus Anzeigenamen abgeleitet + -c{id} für Eindeutigkeit
(Umbenennung, Kollisionen nach Slugify). Nur [a-z0-9-], keine Pfad-Sonderzeichen.
Fehlt der Name / Slug leer verein-c{id}.
"""
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
raw = unicodedata.normalize("NFKC", (club_name or "").strip()).translate(_CLUB_SLUG_TRANS).lower()
raw = re.sub(r"[^a-z0-9]+", "-", raw)
raw = raw.strip("-")
if len(raw) > 48:
raw = raw[:48].rstrip("-")
if not raw:
base = f"verein-c{cid}"
else:
base = f"{raw}-c{cid}"
if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", base):
raise ValueError("Ungültiger abgeleiteter Vereins-Ordnername")
return base
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("/")
@ -55,3 +133,97 @@ def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
except ValueError:
return None
return p
def library_storage_key(
visibility: str,
club_id: Optional[int],
sha256_hex: str,
ext: str,
*,
uploader_profile_id: Optional[int] = None,
mime_type: Optional[str] = None,
club_name: Optional[str] = None,
) -> str:
"""
Relativer Speicherpfad unter MEDIA_ROOT für lokale media_assets.
- official library/official/{kind}/{sha256}{ext}
- club (vereinsgeteilt) library/{vereins-segment}/{kind}/{sha256}{ext}
- private dieselbe Ordnerlogik wie Verein: library/{vereins-segment}/{kind}/{sha}.u{profile}{ext}
Dateiname bei private: {sha}.u{profile_id}{ext} (nicht Unterordner u{}), damit Ordnerstruktur wie bei Verein.
Vereins-Segment: aus club_name abgeleitet + -c{club_id} siehe library_club_path_segment.
kind {image, video, pdf, other} siehe library_media_kind_dir.
Kein Ordnername private auf der Platte. Private Dateien unterscheiden sich nur im Dateinamen (.u{Profil} vor Endung).
"""
vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"):
raise ValueError(f"Ungültige Sichtbarkeit für Speicherpfad: {visibility!r}")
sha = (sha256_hex or "").strip().lower()
if len(sha) != 64 or any(c not in "0123456789abcdef" for c in sha):
raise ValueError("sha256_hex muss 64 Hex-Zeichen sein")
e = (ext or "").strip()
if not e:
e = ".bin"
if not e.startswith("."):
e = "." + e
if ".." in e or "/" in e or "\\" in e or "\x00" in e:
raise ValueError("Ungültige Dateiendung")
kind = library_media_kind_dir(mime_type, e)
e = e[:16]
club_blob = f"{kind}/{sha}{e}"
if vis == "official":
return f"library/official/{club_blob}"
if club_id is None:
raise ValueError("Verein (club_id) ist für diese Sichtbarkeit auf der Platte erforderlich")
cid = int(club_id)
if cid < 1:
raise ValueError("club_id muss eine positive Ganzzahl sein")
club_seg = library_club_path_segment(cid, club_name)
if vis == "club":
return f"library/{club_seg}/{club_blob}"
if uploader_profile_id is None:
raise ValueError("uploader_profile_id ist für private Archiv-Medien erforderlich")
up = int(uploader_profile_id)
if up < 1:
raise ValueError("uploader_profile_id muss positiv sein")
priv_name = f"{sha}.u{up}{e}"
return f"library/{club_seg}/{kind}/{priv_name}"
def relocate_local_media_file(media_root: Path, old_storage_key: str, new_storage_key: str) -> None:
"""
Physisches Verschieben bei geändertem library_*-Pfad (z. B. nach PATCH visibility/club_id).
- Kein Op, wenn Quelle fehlt aber Ziel bereits existiert (idempotent).
- Erwartet storage_backend=local; Aufrufer prüft das.
"""
if (old_storage_key or "").replace("\\", "/").lstrip("/") == (new_storage_key or "").replace(
"\\", "/"
).lstrip("/"):
return
old_p = path_under_media_root(media_root, old_storage_key)
new_p = path_under_media_root(media_root, new_storage_key)
if old_p is None or new_p is None:
raise ValueError("Ungültiger Speicherpfad (Path-Traversal)")
if new_p.is_file():
if not old_p.is_file():
return
if old_p.resolve() == new_p.resolve():
return
raise FileExistsError(f"Zieldatei existiert bereits: {new_storage_key}")
if not old_p.is_file():
raise FileNotFoundError(f"Medien-Quelldatei nicht gefunden: {old_storage_key}")
new_p.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(old_p), str(new_p))

View File

@ -28,7 +28,7 @@ from club_tenancy import (
library_content_visible_to_profile,
)
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible, library_content_visibility_sql
from media_storage import get_effective_media_root, path_under_media_root
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root
logger = logging.getLogger(__name__)
@ -107,9 +107,76 @@ _MAX_UPLOAD_MB_ADMIN = max(_MAX_UPLOAD_MB_USER, int(os.getenv("EXERCISE_MEDIA_AD
MAX_UPLOAD_BYTES_USER = _MAX_UPLOAD_MB_USER * 1024 * 1024
MAX_UPLOAD_BYTES_ADMIN = _MAX_UPLOAD_MB_ADMIN * 1024 * 1024
ALLOWED_UPLOAD_MIMES = frozenset(
{"image/jpeg", "image/png", "image/gif", "video/mp4", "application/pdf"}
{
"image/jpeg",
"image/png",
"image/gif",
"image/heic",
"image/heif",
"video/mp4",
"video/quicktime",
"application/pdf",
}
)
# Dateiendung → MIME wenn der Client keinen sinnvollen Content-Type sendet (häufig mobil / iOS).
_UPLOAD_FILENAME_MIME_FALLBACK = {
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".png": "image/png",
".gif": "image/gif",
".heic": "image/heic",
".heif": "image/heif",
".mp4": "video/mp4",
".mov": "video/quicktime",
".pdf": "application/pdf",
}
def _sniff_allowed_upload_mime(raw: bytes) -> Optional[str]:
"""Erkennt erlaubte MIME-Typen anhand weniger Magic Bytes (ohne Pillow/python-magic)."""
if len(raw) < 12:
return None
if raw[:3] == b"\xff\xd8\xff":
return "image/jpeg"
if raw[:8] == b"\x89PNG\r\n\x1a\n":
return "image/png"
if raw[:6] in (b"GIF87a", b"GIF89a"):
return "image/gif"
if raw[:4] == b"%PDF":
return "application/pdf"
if raw[4:8] != b"ftyp":
return None
brand = raw[8:12]
# HEIC / HEIF (u. a. iPhone „高效率“)
if brand in (b"heic", b"heix", b"hevx", b"hevc", b"mif1", b"msf1"):
return "image/heic"
if brand in (b"isom", b"iso2", b"iso5", b"iso6", b"mp41", b"mp42", b"M4V ", b"dash", b"msdh"):
return "video/mp4"
# iPhone-Kamera / Fotos: MOV (QuickTime-Container)
if brand == b"qt ":
return "video/quicktime"
return None
def resolve_upload_mime_type(
raw: bytes,
content_type: Optional[str],
filename: Optional[str],
) -> str:
"""Ermittelt ein erlaubtes MIME (Client-Header, Magic Bytes oder Dateiendung)."""
ct = (content_type or "").split(";")[0].strip().lower()
if ct in ALLOWED_UPLOAD_MIMES:
return ct
guessed = _sniff_allowed_upload_mime(raw)
if guessed in ALLOWED_UPLOAD_MIMES:
return guessed
ext = Path(filename or "").suffix.lower()
fb = _UPLOAD_FILENAME_MIME_FALLBACK.get(ext)
if fb in ALLOWED_UPLOAD_MIMES:
return fb
raise ValueError(f"Dateityp nicht erlaubt: {ct or 'unbekannt'}")
def _upload_limit_bytes(tenant: TenantContext) -> int:
role = tenant.global_role or ""
@ -575,6 +642,72 @@ def apply_official_exercise_media_rules(
)
def apply_club_exercise_media_copyright_rules(cur, exercise_id: int, next_visibility: str) -> None:
"""
Vereins-sichtbare Übung: angehängte Archiv-Dateien müssen aktiv sein und einen Copyright-Vermerk haben
(wie bei offiziellen Übungen, ohne Sichtbarkeits-Promotion der Assets).
"""
nv = (next_visibility or "private").strip().lower()
if nv != "club":
return
rows = _fetch_exercise_linked_file_assets(cur, exercise_id)
if not rows:
return
blocking_lc: List[Dict[str, Any]] = []
missing_cr: List[Dict[str, Any]] = []
for r in rows:
aid = int(r["id"])
lc = (r.get("lifecycle_state") or "").strip().lower()
cr = _normalize_media_copyright_notice(r.get("copyright_notice"))
if lc != "active":
blocking_lc.append(
{
"media_asset_id": aid,
"lifecycle_state": lc,
"visibility": (r.get("visibility") or "").strip().lower(),
"original_filename": r.get("original_filename"),
}
)
continue
if len(cr) < _MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN:
missing_cr.append(
{
"media_asset_id": aid,
"original_filename": r.get("original_filename"),
}
)
if blocking_lc:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_LIFECYCLE",
"message": (
"Nicht aktive Archiv-Medien dürfen nicht an einer vereinsöffentlichen Übung hängen "
"(Papierkorb/Recovery zuerst)."
),
"media_assets": blocking_lc,
},
)
if missing_cr:
raise HTTPException(
status_code=422,
detail={
"code": "CLUB_MEDIA_COPYRIGHT_REQUIRED",
"message": (
f"Für vereinsöffentliche Übungen ist ein Copyright-Vermerk pro Datei erforderlich "
f"(mind. {_MIN_OFFICIAL_MEDIA_COPYRIGHT_LEN} Zeichen)."
),
"media_assets": missing_cr,
},
)
def _abs_media_path(file_path_db: str, media_root: Path) -> Optional[Path]:
if not file_path_db or file_path_db.startswith("http"):
return None
@ -1180,6 +1313,23 @@ def bulk_patch_exercises_metadata(
failed.append(entry)
continue
if (next_vis or "").strip().lower() == "club":
try:
apply_club_exercise_media_copyright_rules(cur, ex_id, next_vis)
except HTTPException as he:
d = he.detail
entry: Dict[str, Any] = {"id": ex_id}
if isinstance(d, dict):
entry["detail"] = str(d.get("message") or d.get("code") or "Vereins-Medien-Validierung fehlgeschlagen")
if "code" in d:
entry["code"] = d["code"]
if "media_assets" in d:
entry["media_assets"] = d["media_assets"]
else:
entry["detail"] = _fail_msg(he)
failed.append(entry)
continue
sets: List[str] = []
vals: List[Any] = []
if patch_visibility:
@ -1690,13 +1840,13 @@ def create_exercise(
)
row = cur.fetchone()
exercise_id = row['id'] if isinstance(row, dict) else row[0]
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
if (body.visibility or "").strip().lower() == "club":
apply_club_exercise_media_copyright_rules(cur, exercise_id, "club")
conn.commit()
# M:N Relations zuweisen
data = body.dict()
assign_exercise_relations(cur, conn, exercise_id, data)
# Vollständiges Objekt zurückgeben
exercise = enrich_exercise_detail(exercise_id, cur)
return exercise
@ -1799,6 +1949,11 @@ def update_exercise(
cur.execute(query, params)
assign_exercise_relations(cur, conn, exercise_id, data, do_commit=False)
try:
apply_club_exercise_media_copyright_rules(cur, exercise_id, next_vis)
except HTTPException:
conn.rollback()
raise
conn.commit()
exercise = enrich_exercise_detail(exercise_id, cur)
@ -2302,17 +2457,21 @@ async def upload_exercise_media(
status_code=413,
detail=f"Datei zu groß (max. {max_upload // (1024 * 1024)} MB)",
)
mime = file.content_type or ""
if mime not in ALLOWED_UPLOAD_MIMES:
raise HTTPException(
status_code=400,
detail=f"Dateityp nicht erlaubt: {mime or 'unbekannt'}",
)
try:
mime = resolve_upload_mime_type(raw, file.content_type, file.filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
ext = Path(file.filename or "").suffix[:12] if file.filename else ""
if not ext and mime == "image/jpeg":
ext = ".jpg"
elif not ext and mime == "image/png":
ext = ".png"
elif not ext and mime in ("image/heic", "image/heif"):
ext = ".heic"
elif not ext and mime == "video/mp4":
ext = ".mp4"
elif not ext and mime == "video/quicktime":
ext = ".mov"
cur.execute(
"SELECT visibility, club_id, created_by FROM exercises WHERE id = %s",
@ -2326,13 +2485,41 @@ async def upload_exercise_media(
media_root = get_effective_media_root(cur)
full_sha = hashlib.sha256(raw).hexdigest()
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, ex_vis, ex_club),
)
if ex_vis == "official":
dedupe_club: Optional[int] = None
elif ex_vis == "private":
dedupe_club = int(ex_club) if ex_club is not None else tenant.effective_club_id
if dedupe_club is None:
raise HTTPException(
status_code=400,
detail=(
"Private Übungsmedien werden pro Verein gespeichert. Bitte der Übung einen Verein zuordnen "
"oder einen aktiven Verein wählen (X-Active-Club-Id)."
),
)
dedupe_club = int(dedupe_club)
else:
if ex_club is None:
raise HTTPException(status_code=400, detail="Vereinsübung ohne club_id")
dedupe_club = int(ex_club)
if ex_vis == "private":
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = 'private'
AND (club_id IS NOT DISTINCT FROM %s)
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, dedupe_club, profile_id),
)
else:
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, ex_vis, dedupe_club),
)
existing_asset = cur.fetchone()
if existing_asset:
@ -2419,7 +2606,23 @@ async def upload_exercise_media(
},
)
else:
storage_key = f"exercises/{full_sha}{ext}"
club_nm = ""
if dedupe_club is not None:
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(dedupe_club),))
cnr = cur.fetchone()
club_nm = str(r2d(cnr).get("name") or "") if cnr else ""
try:
storage_key = library_storage_key(
ex_vis,
dedupe_club if ex_vis != "official" else None,
full_sha,
ext,
uploader_profile_id=profile_id if ex_vis == "private" else None,
mime_type=mime,
club_name=club_nm,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
dest_path = path_under_media_root(media_root, storage_key)
if dest_path is None:
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
@ -2439,7 +2642,7 @@ async def upload_exercise_media(
full_sha,
file.filename,
ex_vis,
ex_club,
dedupe_club,
profile_id,
storage_key,
),

View File

@ -3,10 +3,14 @@ from __future__ import annotations
from typing import Any, Literal, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, Query, Request
import hashlib
from pathlib import Path
from fastapi import APIRouter, Depends, File, Form, HTTPException, Query, Request, UploadFile
from pydantic import BaseModel, Field, model_validator
from club_tenancy import (
assert_club_member,
assert_valid_governance_visibility,
club_ids_for_profile_with_roles,
is_platform_admin,
@ -30,9 +34,11 @@ from media_lifecycle import (
transition_to_trash_hidden,
transition_to_trash_soft,
)
from media_storage import get_effective_media_root, path_under_media_root
from media_storage import get_effective_media_root, library_storage_key, path_under_media_root, relocate_local_media_file
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
from routers.exercises import _upload_limit_bytes, resolve_upload_mime_type
router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
@ -288,17 +294,89 @@ def _fetch_filter_uploaders(cur: Any, is_adm: bool, profile_id: int) -> list[dic
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
"""Nach visibility-Wechsel club_id konsistent setzen (official/private → NULL)."""
"""
Nach visibility-Wechsel club_id konsistent setzen.
official club_id NULL. private/club: club_id aus Patch oder bisheriger Zeile beibehalten
(private nutzt club_id für Vereinsordner auf der Platte).
"""
eff = dict(patch_fields)
if eff.get("visibility") is not None:
v = str(eff["visibility"]).strip().lower()
if v in ("official", "private"):
if v == "official":
eff["club_id"] = None
elif v == "club" and "club_id" not in eff:
eff["club_id"] = asset.get("club_id")
elif v == "private" and "club_id" not in eff:
eff["club_id"] = asset.get("club_id")
return eff
def _club_display_name_for_storage(cur: Any, club_id: int) -> str:
"""Anzeigename für library_club_path_segment; leer wenn Verein fehlt."""
cur.execute("SELECT name FROM clubs WHERE id = %s", (int(club_id),))
row = cur.fetchone()
if not row:
return ""
return str(r2d(row).get("name") or "")
def _relocate_asset_file_if_governance_changed(
cur: Any,
media_root: Path,
asset_id: int,
asset: dict,
next_vis: str,
next_club_id: Optional[int],
) -> Optional[str]:
"""
Passt bei local-Assets den Dateipfad an, wenn sich Sichtbarkeit/Verein ändert.
Aktualisiert exercise_media.file_path. Gibt neuen storage_key oder None zurück.
"""
if (asset.get("storage_backend") or "local") != "local":
return None
old_key = (asset.get("storage_key") or "").strip()
sha = (asset.get("sha256") or "").strip().lower()
if not old_key or len(sha) != 64:
return None
ext = Path(old_key.replace("\\", "/")).suffix or ".bin"
try:
up = asset.get("uploaded_by_profile_id")
up_i = int(up) if up is not None else None
club_nm: Optional[str] = None
if next_vis in ("club", "private") and next_club_id is not None:
club_nm = _club_display_name_for_storage(cur, next_club_id)
new_key = library_storage_key(
next_vis,
next_club_id if next_vis != "official" else None,
sha,
ext,
uploader_profile_id=up_i if next_vis == "private" else None,
mime_type=str(asset.get("mime_type") or "") or None,
club_name=club_nm,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
old_norm = old_key.replace("\\", "/").lstrip("/")
if new_key == old_norm:
return None
try:
relocate_local_media_file(media_root, old_key, new_key)
except FileNotFoundError as e:
raise HTTPException(status_code=500, detail=f"Mediendatei fehlt auf der Platte: {e}") from e
except FileExistsError as e:
raise HTTPException(status_code=409, detail=str(e)) from e
except (ValueError, OSError) as e:
raise HTTPException(status_code=500, detail=str(e)) from e
db_path = f"/media/{new_key}"
cur.execute(
"UPDATE exercise_media SET file_path = %s WHERE media_asset_id = %s",
(db_path, asset_id),
)
return new_key
def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS:
@ -349,25 +427,25 @@ def _item_permissions(row: dict, tenant: TenantContext, admin_club_ids: set[int]
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)
)
if vis == "official":
edit_metadata = sup
trash_soft = lc == "active" and sup
can_manage_adv = sup
else:
edit_metadata = (
sup
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)
)
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
@ -445,6 +523,243 @@ def _apply_lifecycle_action(
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
_MAX_BULK_LIBRARY_FILES = 25
def _ingest_library_media_file(
cur: Any,
tenant: TenantContext,
raw: bytes,
filename: Optional[str],
content_type: Optional[str],
visibility: str,
club_id_form: Optional[int],
) -> dict:
"""Neues Archiv-Medium oder aktiver Dedupe-Treffer (sha256 + Sichtbarkeit + Verein). Kein exercise_media."""
profile_id = tenant.profile_id
role = tenant.global_role or ""
vis = (visibility or "private").strip().lower()
if vis not in ("private", "club", "official"):
raise HTTPException(status_code=400, detail="Ungültige Sichtbarkeit")
next_cid: Optional[int] = None
if vis == "club":
if club_id_form is None:
raise HTTPException(status_code=400, detail="Verein erforderlich für Sichtbarkeit „Verein“")
next_cid = int(club_id_form)
elif vis == "private":
if club_id_form is not None:
next_cid = int(club_id_form)
elif is_platform_admin(role):
raise HTTPException(
status_code=400,
detail=(
"Private Archiv-Uploads als Plattform-Admin: bitte den Zielverein wählen und "
"club_id im Formular setzen (nicht vom allgemeinen Kontext ableiten)."
),
)
else:
cid = tenant.effective_club_id
next_cid = int(cid) if cid is not None else None
if next_cid is None:
raise HTTPException(
status_code=400,
detail=(
"Private Medien werden pro Verein abgelegt. Bitte aktiven Verein setzen "
"(Header X-Active-Club-Id) oder club_id im Formular übergeben — auch für Plattform-Admins."
),
)
if not is_platform_admin(role):
assert_club_member(cur, profile_id, next_cid)
assert_valid_governance_visibility(cur, profile_id, role, vis, next_cid if vis == "club" else None)
max_b = _upload_limit_bytes(tenant)
if len(raw) > max_b:
raise HTTPException(
status_code=413,
detail=f"Datei zu groß (max. {max_b // (1024 * 1024)} MB)",
)
try:
mime = resolve_upload_mime_type(raw, content_type, filename)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
full_sha = hashlib.sha256(raw).hexdigest()
if vis == "private":
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
AND (uploaded_by_profile_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, vis, next_cid, profile_id),
)
else:
cur.execute(
"""SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets
WHERE sha256 = %s AND lower(trim(visibility)) = %s
AND (club_id IS NOT DISTINCT FROM %s)
LIMIT 1""",
(full_sha, vis, next_cid),
)
existing_asset = cur.fetchone()
if existing_asset:
ea = r2d(existing_asset)
lc = (ea.get("lifecycle_state") or "").strip().lower()
if lc == "active":
return {
"status": "duplicate",
"media_asset_id": int(ea["id"]),
"original_filename": ea.get("original_filename"),
}
if lc in ("trash_soft", "trash_hidden"):
raise HTTPException(
status_code=409,
detail={
"code": "MEDIA_ASSET_IN_TRASH",
"message": (
"Diese Datei ist inhaltsgleich (SHA-256) mit einem Archiv-Medium im Papierkorb."
),
"media_asset_id": ea["id"],
"lifecycle_state": lc,
},
)
raise HTTPException(
status_code=409,
detail="Es existiert bereits ein Archiv-Eintrag zu dieser Datei in einem nicht nutzbaren Zustand.",
)
ext = Path(filename or "").suffix[:12] if filename else ""
if not ext and mime == "image/jpeg":
ext = ".jpg"
elif not ext and mime == "image/png":
ext = ".png"
elif not ext and mime in ("image/heic", "image/heif"):
ext = ".heic"
elif not ext and mime == "video/mp4":
ext = ".mp4"
elif not ext and mime == "video/quicktime":
ext = ".mov"
club_nm = ""
if next_cid is not None:
club_nm = _club_display_name_for_storage(cur, next_cid)
media_root = get_effective_media_root(cur)
try:
storage_key = library_storage_key(
vis,
next_cid,
full_sha,
ext,
uploader_profile_id=profile_id if vis == "private" else None,
mime_type=mime,
club_name=club_nm,
)
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e)) from e
dest_path = path_under_media_root(media_root, storage_key)
if dest_path is None:
raise HTTPException(status_code=500, detail="Ungültiger Speicherpfad")
dest_path.parent.mkdir(parents=True, exist_ok=True)
if not dest_path.is_file():
dest_path.write_bytes(raw)
cur.execute(
"""INSERT INTO media_assets (
mime_type, byte_size, sha256, original_filename, visibility, club_id,
uploaded_by_profile_id, copyright_notice, storage_backend, storage_key, lifecycle_state
) VALUES (%s, %s, %s, %s, %s, %s, %s, NULL, 'local', %s, 'active')
RETURNING id""",
(
mime,
len(raw),
full_sha,
filename or storage_key,
vis,
next_cid,
profile_id,
storage_key,
),
)
ar = cur.fetchone()
aid = int(r2d(ar)["id"])
return {"status": "created", "media_asset_id": aid, "original_filename": filename or storage_key}
@router.post("/bulk-upload")
async def bulk_upload_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
files: list[UploadFile] = File(..., description="Mehrere Dateien (jpeg, png, gif, mp4, pdf)"),
visibility: str = Form("private"),
club_id: Optional[int] = Form(None),
):
"""Mehrere Dateien ins Archiv; Dedupe wie Übungs-Upload. Pro Datei eigene DB-Transaktion."""
if not files:
raise HTTPException(status_code=400, detail="Keine Dateien übermittelt")
if len(files) > _MAX_BULK_LIBRARY_FILES:
raise HTTPException(
status_code=400,
detail=f"Maximal {_MAX_BULK_LIBRARY_FILES} Dateien pro Anfrage",
)
results: list[dict[str, Any]] = []
created = duplicate = failed = 0
for uf in files:
fn = uf.filename or "ohne_name"
try:
raw = await uf.read()
if not raw:
results.append({"filename": fn, "ok": False, "detail": "Leere Datei"})
failed += 1
continue
with get_db() as conn:
cur = get_cursor(conn)
r = _ingest_library_media_file(
cur,
tenant,
raw,
uf.filename,
uf.content_type,
visibility,
club_id,
)
results.append({"filename": fn, "ok": True, **r})
if r["status"] == "created":
created += 1
else:
duplicate += 1
except HTTPException as e:
detail = e.detail
if isinstance(detail, dict):
detail_s = detail.get("message") or detail.get("code") or str(detail)
else:
detail_s = str(detail)
results.append(
{
"filename": fn,
"ok": False,
"status_code": e.status_code,
"detail": detail_s,
},
)
failed += 1
except Exception as e:
results.append({"filename": fn, "ok": False, "detail": str(e)})
failed += 1
return {
"results": results,
"created_count": created,
"duplicate_count": duplicate,
"failed_count": failed,
}
@router.get("")
def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context),
@ -489,21 +804,22 @@ def list_media_assets(
media_kind_sql = ""
if mk == "image":
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%'"
# %% für psycopg2: sonst wird % als Platzhalter-Syntax interpretiert
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%%'"
elif mk == "video":
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%'"
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%%'"
elif mk == "pdf":
media_kind_sql = (
" AND (lower(COALESCE(ma.mime_type, '')) = 'application/pdf'"
" OR lower(COALESCE(ma.mime_type, '')) LIKE '%pdf%')"
" OR lower(COALESCE(ma.mime_type, '')) LIKE '%%pdf%%')"
)
elif mk == "other":
media_kind_sql = (
" AND COALESCE(ma.mime_type, '') <> ''"
" AND lower(ma.mime_type) NOT LIKE 'image/%'"
" AND lower(ma.mime_type) NOT LIKE 'video/%'"
" AND lower(ma.mime_type) NOT LIKE 'image/%%'"
" AND lower(ma.mime_type) NOT LIKE 'video/%%'"
" AND lower(ma.mime_type) <> 'application/pdf'"
" AND lower(ma.mime_type) NOT LIKE '%pdf%'"
" AND lower(ma.mime_type) NOT LIKE '%%pdf%%'"
)
club_sql = ""
@ -721,7 +1037,7 @@ def bulk_media_patch(
try:
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type
FROM media_assets WHERE id = %s""",
(asset_id,),
)
@ -743,7 +1059,7 @@ def bulk_media_patch(
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")
next_cid = eff.get("club_id", asset.get("club_id"))
if "visibility" in patch_fields or "club_id" in patch_fields:
assert_valid_governance_visibility(
cur,
@ -752,6 +1068,26 @@ def bulk_media_patch(
next_vis,
int(next_cid) if next_cid is not None else None,
)
if next_vis in ("private", "club") and next_cid is None:
failed.append(
{
"id": asset_id,
"detail": (
"Für private oder Vereins-Medien wird club_id benötigt (Vereinsordner)."
),
}
)
continue
new_sk: Optional[str] = None
if "visibility" in patch_fields or "club_id" in patch_fields:
next_club_param: Optional[int] = None
if next_vis in ("club", "private"):
next_club_param = int(next_cid) if next_cid is not None else None
media_root = get_effective_media_root(cur)
new_sk = _relocate_asset_file_if_governance_changed(
cur, media_root, asset_id, asset, next_vis, next_club_param
)
sets: list[str] = []
vals: list[Any] = []
@ -768,7 +1104,10 @@ def bulk_media_patch(
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
vals.append(next_cid)
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if not sets:
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
continue
@ -802,7 +1141,7 @@ def patch_media_asset(
cur = get_cursor(conn)
cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
copyright_notice, original_filename, sha256, storage_key, storage_backend, mime_type
FROM media_assets WHERE id = %s""",
(asset_id,),
)
@ -820,7 +1159,7 @@ def patch_media_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")
next_cid = eff.get("club_id", asset.get("club_id"))
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(
cur,
@ -829,6 +1168,24 @@ def patch_media_asset(
next_vis,
int(next_cid) if next_cid is not None else None,
)
if next_vis in ("private", "club") and next_cid is None:
raise HTTPException(
status_code=400,
detail=(
"Für private oder Vereins-Medien wird club_id benötigt (Vereinsordner). "
"Bitte im PATCH setzen, z. B. bei Wechsel von „offiziell“ zu „privat“."
),
)
new_sk: Optional[str] = None
if "visibility" in data or "club_id" in data:
next_club_param: Optional[int] = None
if next_vis in ("club", "private"):
next_club_param = int(next_cid) if next_cid is not None else None
media_root = get_effective_media_root(cur)
new_sk = _relocate_asset_file_if_governance_changed(
cur, media_root, asset_id, asset, next_vis, next_club_param
)
sets: list[str] = []
vals: list[Any] = []
@ -845,7 +1202,10 @@ def patch_media_asset(
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
vals.append(next_cid)
if new_sk:
sets.append("storage_key = %s")
vals.append(new_sk)
if sets:
sets.append("updated_at = NOW()")
vals.append(asset_id)

View File

@ -80,7 +80,7 @@ def library_content_visibility_sql(
class TenantContext:
profile_id: int
global_role: str
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header oft None
# Header > gespeichertes Profil > Fallback; Plattform-Admin ohne Header: Profil-Verein wenn existent
effective_club_id: Optional[int]
club_ids: frozenset[int]
memberships: List[Dict[str, Any]]
@ -100,7 +100,8 @@ def resolve_tenant_context(
Mitgliedschaften: wenn nicht übergeben, wird aus der DB geladen (aktive Mitgliedschaften).
Auflösung effective_club_id:
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header None.
- Plattform-Admin: Header setzt beliebigen existierenden Verein; ohne Header gespeichertes
active_club_id falls der Verein existiert, sonst None.
- Sonst: gültiger Header zwingend Mitgliedschaft bei ``reject`` sonst 403, bei ``ignore`` wie ohne Header.
Ohne gültigen Header: gespeichertes active_club_id wenn Mitglied; sonst einziger Verein;
bei mehreren ohne gültige Vorgabe min(club_ids) (Fallback).
@ -118,6 +119,11 @@ def resolve_tenant_context(
if not _club_exists(cur, header_cid):
raise HTTPException(status_code=400, detail="Verein nicht gefunden")
effective = header_cid
elif (
stored_active_club_id is not None
and _club_exists(cur, stored_active_club_id)
):
effective = stored_active_club_id
else:
effective = None
return TenantContext(

View File

@ -2,7 +2,7 @@
import pytest
from fastapi import HTTPException
from tenant_context import library_content_visibility_sql, parse_active_club_header
from tenant_context import library_content_visibility_sql, parse_active_club_header, resolve_tenant_context
def test_library_visibility_sql_platform_admin_no_filter():
@ -74,3 +74,54 @@ def test_parse_active_club_header_non_positive():
with pytest.raises(HTTPException) as exc:
parse_active_club_header("0")
assert exc.value.status_code == 400
def test_resolve_platform_admin_uses_stored_club_without_header(monkeypatch):
"""Ohne X-Active-Club-Id: effective wie gespeichertes Profil (Sync mit Dropdown/DB)."""
cur = object()
def fake_exists(c, cid):
return cid == 99
monkeypatch.setattr("tenant_context._club_exists", fake_exists)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="admin",
header_raw=None,
memberships=[{"id": 10}],
stored_active_club_id=99,
)
assert ctx.effective_club_id == 99
def test_resolve_platform_admin_header_overrides_stored(monkeypatch):
cur = object()
def fake_exists(c, cid):
return cid in (5, 99)
monkeypatch.setattr("tenant_context._club_exists", fake_exists)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="superadmin",
header_raw="5",
memberships=[{"id": 10}],
stored_active_club_id=99,
)
assert ctx.effective_club_id == 5
def test_resolve_platform_admin_no_header_stored_invalid(monkeypatch):
cur = object()
monkeypatch.setattr("tenant_context._club_exists", lambda c, cid: False)
ctx = resolve_tenant_context(
cur,
profile_id=1,
global_role="admin",
header_raw=None,
memberships=[{"id": 1}],
stored_active_club_id=123,
)
assert ctx.effective_club_id is None

View File

@ -0,0 +1,62 @@
"""Vereins-Übung: Copyright-Pflicht für angehängte Archiv-Dateien."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
import routers.exercises as exercises_mod
from routers.exercises import apply_club_exercise_media_copyright_rules
def test_apply_club_exercise_media_copyright_skips_non_club() -> None:
apply_club_exercise_media_copyright_rules(object(), 1, "private")
def test_apply_club_exercise_media_copyright_missing_copyright() -> None:
orig = exercises_mod._fetch_exercise_linked_file_assets
def mock_fetch(_cur, eid: int):
assert eid == 42
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "",
"original_filename": "x.jpg",
}
]
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
try:
with pytest.raises(HTTPException) as ei:
apply_club_exercise_media_copyright_rules(object(), 42, "club")
assert ei.value.status_code == 422
d = ei.value.detail
assert isinstance(d, dict)
assert d.get("code") == "CLUB_MEDIA_COPYRIGHT_REQUIRED"
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig
def test_apply_club_exercise_media_copyright_ok() -> None:
orig = exercises_mod._fetch_exercise_linked_file_assets
def mock_fetch(_cur, _eid: int):
return [
{
"id": 10,
"visibility": "private",
"club_id": 1,
"lifecycle_state": "active",
"copyright_notice": "© Verein 2026",
"original_filename": "x.jpg",
}
]
exercises_mod._fetch_exercise_linked_file_assets = mock_fetch
try:
apply_club_exercise_media_copyright_rules(object(), 42, "club")
finally:
exercises_mod._fetch_exercise_linked_file_assets = orig

View File

@ -0,0 +1,107 @@
"""library_storage_key: Mandantenpfade unter MEDIA_ROOT."""
from __future__ import annotations
import pytest
from media_storage import library_club_path_segment, library_media_kind_dir, library_storage_key
_HEX64 = "a" * 64
def test_library_media_kind_dir_mime_overrides_ext() -> None:
assert library_media_kind_dir("image/png", ".bin") == "image"
assert library_media_kind_dir("application/pdf", ".tmp") == "pdf"
def test_library_club_path_segment_slug_and_fallback() -> None:
assert library_club_path_segment(3, "Grün & Weiß / Dojo!") == "gruen-weiss-dojo-c3"
assert library_club_path_segment(3, None) == "verein-c3"
assert library_club_path_segment(3, " ") == "verein-c3"
assert library_club_path_segment(12, "東道場") == "verein-c12"
def test_library_club_path_segment_long_name_truncated() -> None:
long = "a" * 80
seg = library_club_path_segment(5, long)
assert seg == "a" * 48 + "-c5"
def test_library_storage_key_private_is_uploader_under_club() -> None:
assert (
library_storage_key(
"private", 7, _HEX64, ".jpg", uploader_profile_id=99, club_name="Ost Dojo München"
)
== f"library/ost-dojo-muenchen-c7/image/{_HEX64}.u99.jpg"
)
def test_library_storage_key_club_flat_under_club() -> None:
assert (
library_storage_key("club", 42, _HEX64, ".png", club_name="Demo-Verein")
== f"library/demo-verein-c42/image/{_HEX64}.png"
)
def test_library_storage_key_official() -> None:
assert (
library_storage_key("official", None, _HEX64, ".mp4", uploader_profile_id=1)
== f"library/official/video/{_HEX64}.mp4"
)
def test_library_storage_key_normalizes_visibility() -> None:
assert (
library_storage_key(" CLUB ", 1, _HEX64, "pdf", club_name="Alpha")
== f"library/alpha-c1/pdf/{_HEX64}.pdf"
)
def test_library_storage_key_other_bin() -> None:
assert (
library_storage_key("official", None, _HEX64, "", mime_type="application/octet-stream")
== f"library/official/other/{_HEX64}.bin"
)
def test_library_storage_key_private_requires_uploader() -> None:
with pytest.raises(ValueError, match="uploader_profile_id"):
library_storage_key("private", 1, _HEX64, ".jpg", club_name="V")
def test_library_storage_key_private_requires_club() -> None:
with pytest.raises(ValueError, match="Verein"):
library_storage_key("private", None, _HEX64, ".jpg", uploader_profile_id=1)
def test_library_storage_key_club_requires_id() -> None:
with pytest.raises(ValueError, match="Verein"):
library_storage_key("club", None, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_club_id_positive() -> None:
with pytest.raises(ValueError, match="positiv"):
library_storage_key("club", 0, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_invalid_visibility() -> None:
with pytest.raises(ValueError, match="Sichtbarkeit"):
library_storage_key("public", 1, _HEX64, ".jpg", club_name="X")
def test_library_storage_key_invalid_sha() -> None:
with pytest.raises(ValueError, match="64"):
library_storage_key("private", 1, "deadbeef", ".jpg", uploader_profile_id=1, club_name="X")
def test_library_storage_key_extension() -> None:
with pytest.raises(ValueError):
library_storage_key(
"private", 1, _HEX64, "../x", uploader_profile_id=1, club_name="X"
)
def test_library_storage_key_private_no_club_name_fallback() -> None:
assert (
library_storage_key("private", 2, _HEX64, ".jpg", uploader_profile_id=3)
== f"library/verein-c2/image/{_HEX64}.u3.jpg"
)

View File

@ -15,6 +15,11 @@ from auth import require_auth
from main import app
from tenant_context import TenantContext, get_tenant_context
# Gültige storage_key-Beispiele (64 Hex-Zeichen wie echter SHA-256)
_SK_OFF_A = f"library/official/image/{'a' * 64}.jpg"
_SK_OFF_B = f"library/official/image/{'b' * 64}.jpg"
_SK_PRIV_C = f"library/verein-c1/video/{'c' * 64}.u1.mp4"
@pytest.fixture
def client() -> TestClient:
@ -87,6 +92,32 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
assert "viewer" in body
def test_list_media_assets_media_kind_image_ok_mocked(client: TestClient) -> None:
"""media_kind=image setzt %% in SQL — Regression gegen psycopg2-%-Platzhalter."""
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.side_effect = [[], []]
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?media_kind=image", headers={"X-Auth-Token": "t"})
assert r.status_code == 200
calls = [str(c) for c in mock_cur.execute.call_args_list]
joined = " ".join(calls)
assert "image/%%" in joined
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(
@ -110,7 +141,7 @@ def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/x.jpg",
"storage_key": _SK_OFF_A,
},
{"id": 1},
]
@ -149,7 +180,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"id": 99,
"exercise_id": 3,
"media_type": "image",
"file_path": "/media/exercises/h.jpg",
"file_path": f"/media/{_SK_OFF_B}",
"file_size": 10,
"mime_type": "image/jpeg",
"original_filename": "h.jpg",
@ -176,7 +207,7 @@ def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
"club_id": None,
"uploaded_by_profile_id": 1,
"lifecycle_state": "active",
"storage_key": "exercises/h.jpg",
"storage_key": _SK_OFF_B,
},
None,
inserted,
@ -300,10 +331,10 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
{
"id": 5,
"visibility": "private",
"club_id": None,
"club_id": 1,
"uploaded_by_profile_id": 1,
"lifecycle_state": "trash_soft",
"storage_key": "exercises/a.mp4",
"storage_key": _SK_PRIV_C,
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,

View File

@ -1,23 +1,23 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.51"
APP_VERSION = "0.8.59"
BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507046"
DB_SCHEMA_VERSION = "20260508049"
MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
"profiles": "1.7.0", # exercise_list_prefs JSONB (Standard Übungsfilter); Patch via ProfileUpdate + Json()
"tenant_context": "1.0.4", # pytest: Unit test_access_layer.py + optional Integration test_access_layer_integration (PostgreSQL)
"tenant_context": "1.0.5", # Plattform-Admin: effective_club ohne Header aus Profil active_club_id wenn Verein existiert
"clubs": "0.4.1", # Alle geschützten Endpoints Depends(get_tenant_context); profile_id/role aus TenantContext
"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.5.1", # usage: training_unit_exercises optional (Schema ohne planning-Tabelle)
"media_assets": "1.12.1", # official: nur Superadmin Lifecycle/PATCH; UI Lesemodus; Superadmin Upload-Verein = aktiv
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.16.0", # §4.2 official: angehängte media_assets + Copyright (PUT + bulk-metadata)
"exercises": "2.18.0", # Vereins-Übung: Copyright-Pflicht File-Assets; official nur Superadmin (Governance)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -29,6 +29,66 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.59",
"date": "2026-05-07",
"changes": [
"Aktiver Verein: Backend resolve_tenant_context für Plattform-Admin ohne X-Active-Club-Id nutzt gespeichertes active_club_id wenn der Verein existiert (kein effective=null nach hartem Reload mehr)",
"Frontend: Nach Vereinswechsel Profil neu laden; Dropdown-Wert aus effective_club_id / active_club_id / LocalStorage abgestimmt (getResolvedActiveClubIdForUi)",
],
},
{
"version": "0.8.58",
"date": "2026-05-07",
"changes": [
"Medienbibliothek offiziell: Ändern/Lifecycle nur Superadmin (nicht Plattform-Admin); Bearbeiten-Dialog für andere nur Lesemodus; Superadmin: Vereinsauswahl beim Archiv-Upload folgt aktiv/gesetzt effective_club_id",
],
},
{
"version": "0.8.57",
"date": "2026-05-07",
"changes": [
"Governance: visibility=official nur noch Superadmin (nicht Plattform-Admin)",
"Medienarchiv: private Uploads als Plattform-Admin erfordern explizites club_id; gleiche Ordnerstruktur wie „Verein“, private Kopien mit .u{Profil} vor Dateiendung",
"Übung visibility=Verein: angebundene Archiv-Dateien müssen aktiv sein und Copyright (≥3 Zeichen) haben; UI- und API-Fehlercodes CLUB_MEDIA_*",
"Frontend: „Offiziell“ nur Superadmin (Bibliothek, Übungsformular, Bulk-Sichtbarkeit, Progressionsgraphen)",
],
},
{
"version": "0.8.56",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: Vereinsmedien unter library/{aus Name abgeleitet}-c{club_id}/ statt festem library/club/c{id}/; Sonderzeichen/Umlaute zu sicheren Ordnernamen; fehlender Name → verein-c{id}; Governance-Umzug liest aktuellen Vereinsnamen aus DB",
],
},
{
"version": "0.8.55",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: Speicherpfad library/* mit Medientyp-Unterordnern (image, video, pdf, other) vor SHA-Dateinamen; Umzug bei Governance-PATCH nutzt mime_type aus DB",
],
},
{
"version": "0.8.54",
"date": "2026-05-08",
"changes": [
"Medienpfade: vereinsgeteilt (club) direkt unter library/club/c{id}/; private unter library/club/c{id}/u{profile}/ (kein Ordner „private“/„shared“); Dedupe private inkl. uploaded_by_profile_id",
],
},
{
"version": "0.8.53",
"date": "2026-05-08",
"changes": [
"Medienablage vereinsbezogen: private → library/club/c{id}/private, Vereinssichtbarkeit → …/shared, official unverändert; private Archiv-Upload: club_id oder X-Active-Club-Id; DB club_id bei private gesetzt; PATCH/Bulk: club_id für private nicht mehr blind auf NULL",
],
},
{
"version": "0.8.52",
"date": "2026-05-08",
"changes": [
"Neue lokale media_assets: hierarchische library/* Pfade; Dedupe nach sha+visibility+club_id; bei PATCH/Bulk-Patch Sichtbarkeit/Verein: Datei umziehen, exercise_media.file_path aktualisieren (Details siehe 0.8.53)",
],
},
{
"version": "0.8.51",
"date": "2026-05-07",

View File

@ -1,5 +1,6 @@
# Keine festen container_name — Compose-Namen haben Projektprefix (<projekt>-postgres-1).
# Gleiche Variablennamen wie docker-compose.yml; andere Werte in einer eigenen .env neben dieser Datei.
# Medien: In .env SHINKAN_MEDIA_HOST (Host-Pfad) und optional MEDIA_ROOT (Container-Pfad) setzen.
# Default Host /shinkan-media/dev — Verzeichnis ggf. anlegen oder Compose legt es an.
services:
postgres:
@ -43,8 +44,9 @@ services:
MEDIAWIKI_CATEGORY_SKILLS: "${MEDIAWIKI_CATEGORY_SKILLS:-Fähigkeitsbeschreibung}"
MEDIAWIKI_CATEGORY_METHODS: "${MEDIAWIKI_CATEGORY_METHODS:-Methodenbeschreibung}"
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
volumes:
- dev-shinkan-media:/app/media
- ${SHINKAN_MEDIA_HOST:-/shinkan-media/dev}:${MEDIA_ROOT:-/app/media}
ports:
- "8098:8000"
depends_on:
@ -70,7 +72,6 @@ services:
volumes:
dev-shinkan-db-data:
dev-shinkan-media:
networks:
dev-shinkan-network:

View File

@ -52,8 +52,10 @@ services:
MEDIAWIKI_CATEGORY_SKILLS: "${MEDIAWIKI_CATEGORY_SKILLS:-Fähigkeitsbeschreibung}"
MEDIAWIKI_CATEGORY_METHODS: "${MEDIAWIKI_CATEGORY_METHODS:-Methodenbeschreibung}"
MEDIAWIKI_CATEGORY_MODELS: "${MEDIAWIKI_CATEGORY_MODELS:-Reifegradmodelle}"
# Medien: Host-Pfad SHINKAN_MEDIA_HOST (in .env), Ziel im Container MEDIA_ROOT.
MEDIA_ROOT: "${MEDIA_ROOT:-/app/media}"
volumes:
- shinkan-media:/app/media
- ${SHINKAN_MEDIA_HOST:-/shinkan-media}:${MEDIA_ROOT:-/app/media}
ports:
- "8003:8000"
depends_on:
@ -79,7 +81,6 @@ services:
volumes:
shinkan-db-data:
shinkan-media:
networks:
shinkan-network:

View File

@ -1,9 +1,9 @@
# Shinkan Jinkendo Entwicklungsstand & Handover
**Stand:** 2026-05-05
**App-Version / DB-Schema:** siehe `backend/version.py`
**Stand:** 2026-05-07
**App-Version / DB-Schema:** App **0.8.59**, DB-Schema siehe `backend/version.py` (`DB_SCHEMA_VERSION`)
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand** und **nächste Baustellen**.
Diese Datei ist die **Einstiegs-Doku für neue Chat-Sessions**: Anforderungen im Detail stehen in `.claude/docs/` (siehe unten); hier der **implementierte Stand**, **Medien-Meilenstein** und **sinnvolle nächste Schritte**.
### Produktion: `relation … does not exist` (z.B. `skill_main_categories`)
@ -21,11 +21,17 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
| Thema | Pfad |
|--------|------|
| Projekt-Setup, Domain grob | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
| Projekt-Status (Skills, Wiki, Stats) | `.claude/docs/PROJECT_STATUS.md` |
| **Projekt-Status (aktuell)** | `.claude/docs/PROJECT_STATUS.md` |
| **Medien-Archiv, Lifecycle, Inline-Plan (§11)** | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
| Übungen: API, DB, Architektur, Routing | `.claude/docs/technical/EXERCISES_API_SPEC.md`, `EXERCISES_DATABASE_FINAL.md`, `EXERCISES_ARCHITECTURE.md`, `EXERCISES_FRONTEND_ROUTING.md` |
| Media / Upload | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
| Media / Upload-Limits / Embed | `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` |
| MediaWiki-Import | `.claude/docs/technical/MEDIAWIKI_IMPORT_SPEC.md` |
| Rahmenprogramm · Planung (`training_units` Blueprints), Progressionsgraph | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md`; Überblick DB → `.claude/docs/technical/DATABASE_SCHEMA.md`; Domäne → `.claude/docs/functional/DOMAIN_MODEL.md` |
| Zugriffsschicht, Mandant, Governance | `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` |
| Tenant-Endpoints (Audit) | `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md` |
| Rahmenprogramm · Planung | `.claude/docs/technical/TRAINING_FRAMEWORK_SPEC.md` |
| Überblick DB | `.claude/docs/technical/DATABASE_SCHEMA.md` |
| Domäne | `.claude/docs/functional/DOMAIN_MODEL.md` |
| **Lieferliste inkl. Medien** | `.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md` §12 |
---
@ -36,73 +42,108 @@ Das Schema ist gegenüber dem Code zurück: Migration **`022_skills_schema_compl
- **`maturity_models`**, **`model_levels`**, **`model_skills`**, **`model_skill_levels`**: Matrix-Inhalt pro Modell.
- **Kontext am Modell (Legacy, M:N):** `maturity_model_focus_areas`, `maturity_model_style_directions`, `maturity_model_target_groups` (Migration 025).
- **Hierarchische Kontext-Zuordnung (Resolve):** `maturity_model_context_bindings` mit optional `style_direction_id`, `training_type_id` (Migration 026, 027).
- **027:** u. a. `Fokus + Trainingsstil` ohne Stilrichtung (partielle Unique-Indizes).
- **027:** u.a. `Fokus + Trainingsstil` ohne Stilrichtung (partielle Unique-Indizes).
### 2.2 Resolve-Logik (Backend)
- **`GET /api/maturity-models/resolve`**: Bindings zum Fokus, die zur Anfrage passen; Merge nach Spezifität (weniger spezifisch zuerst); spezifischere Zeilen überschreiben Zelltexte.
- **Matching:** Gesetzte Spalten einer Binding-Zeile müssen mit der Anfrage übereinstimmen; `NULL` in der Zeile = Wildcard (z. B. Fokus+Trainingsstil gilt für alle Stilrichtungen, aber nur für diesen Trainingsstil).
- **Legacy-Fallback:** Nur wenn für den **Fokus keine einzige** Zeile in `maturity_model_context_bindings` existiert. Sonst bei fehlendem Treffer **`null`** (kein stilles Legacy mit falschem Trainingsstil).
- **Matching:** Gesetzte Spalten einer Binding-Zeile müssen mit der Anfrage übereinstimmen; `NULL` in der Zeile = Wildcard.
- **Legacy-Fallback:** Nur wenn für den **Fokus keine einzige** Zeile in `maturity_model_context_bindings` existiert.
### 2.3 Export / Import (einzelnes Modell & aufgelöst)
- **`GET /api/maturity-models/{id}/export`**: `shinkan.maturity_model.v1` inkl. `context_bindings_for_model` (IDs).
- **`GET /api/maturity-models/export-resolved`**: `shinkan.maturity_matrix_resolved.v1` (Query: `focus_area_id`, optional `style_direction_id`, `training_type_id`).
- **`POST /api/maturity-models/import`**: `create` | `replace`, optional `import_bindings` (nur bei `maturity_model.v1`).
- **`POST /api/maturity-models/import`**: `create` | `replace`, optional `import_bindings`.
### 2.4 Komplett-Stack Test → Prod
- **`GET /api/admin/matrix-stack/export`**: `shinkan.matrix_stack.v1` Katalog (`skill_main_categories`, `skill_categories`, `skills`, `skill_level_definitions`) + alle Reifegradmodelle + Bindings mit **Namen** (Fokus/Stil/Trainingsstil).
- **`POST /api/admin/matrix-stack/import`**: Upsert Katalog per Slug; Skills per Kategorie+Name; Modelle neu anlegen; Bindings per Katalognamen. Optional **`replace_all_maturity_models`** + **`confirm_replace_all: "DELETE_MATURITY_STACK"`** (nur Superadmin).
- Router: `backend/routers/matrix_stack_bundle.py`, in `main.py` registriert.
- **`GET /api/admin/matrix-stack/export`**, **`POST /api/admin/matrix-stack/import`** — siehe `matrix_stack_bundle.py`.
### 2.5 Frontend (Admin)
- **`frontend/src/pages/AdminMaturityModelsPage.jsx`**: Tabs u. a. Katalog, Modelle, Kontext-Zuordnung, **Matrix-Ansicht und Export**.
- **`MaturityModelBindingsAdmin.jsx`**: Bindings CRUD, Erklärung Merge/Legacy.
- **`MaturityMatrixToolsAdmin.jsx`**: Kontext auflösen, hierarchische Matrix-Ansicht, Export einzelnes Modell / aufgelöst, Einzelmodell-Import; **Komplett-Stack** mit eigenem Export-Button und **eigenem Dateifeld für Stack-Import** (`POST /api/admin/matrix-stack/import`).
- **`frontend/src/utils/api.js`**: u. a. `exportMatrixStackBundle`, `importMatrixStackBundle`, Reifegrad-APIs.
- **`AdminMaturityModelsPage.jsx`**, **`MaturityModelBindingsAdmin.jsx`**, **`MaturityMatrixToolsAdmin.jsx`**; APIs in `api.js`.
---
## 3. Trainingsrahmenprogramm & PlanungsBlueprint (kurz)
- **Migration 036:** Rahmenkopf nur Bibliothek (Kontext: Fokusbereich, Stilrichtung; M:N Trainingsarten/Zielgruppen); keine `plan_mode`/keine Kopf`group_id`.
- **Migration 037:** Pro RahmenSlot eine **`training_units`Zeile mit `framework_slot_id`**; strukturierter Ablauf wie echte Einheiten (`training_unit_sections` / `training_unit_section_items`). Tabelle **`training_framework_slot_exercises`** entfällt.
- **API:** Rahmen unter **`/api/training-framework-programs`** (Slots liefern u. a. **`blueprint_training_unit_id`**, **`sections[]`**, **`exercises[]`**); Kalenderliste **`GET /api/training-units`** ohne Blueprints; Übernahme **`POST /api/training-units/from-framework-slot`**.
- **Code:** `backend/routers/training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**; **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
- **036 / 037:** Bibliotheks-Rahmen, Slot-Inhalt als **`training_units`** mit **`framework_slot_id`**; **`POST /api/training-units/from-framework-slot`**.
- **Code:** `training_framework_programs.py`, `training_planning.py`; Frontend **`TrainingFrameworkProgramEditPage.jsx`**, **`createTrainingUnitFromFrameworkSlot`** in `api.js`.
---
## 4. Stand: Übungen (Lücke für nächste Session)
## 4. Stand: Medien-Management (Ist, 2026-05-07)
**Ist (laut Projektdoku und aktuellem Produktziel):**
**Datenmodell (Kurz):**
- Backend: Übungen-CRUD, M:N, Suche, Blöcke, Medien-Struktur u. a. sind in `PROJECT_STATUS.md` als umgesetzt geführt; viele Übungen stammen aus **MediaWiki-Import** (Wiki-Tracking-Tabellen).
- **Soll / Nutzerfeedback:** In der Praxis fehlt oder ist unzureichend: **stabile Liste**, **gerenderte Detailansicht**, **Bearbeiten/Anlegen**, **Medien zuweisen/Upload** konkrete Fehler (404, leere Liste, falsche Route) sind vor Ort zu verifizieren.
- **`media_assets`:** Physische oder logische Archiv-Datei (u.a. `sha256`, `visibility`, `club_id`, `lifecycle_state`, `copyright_notice`, Speicherpfad/`storage_key`, `tags` ab passender Migration).
- **`exercise_media`:** optional **`media_asset_id`**; weiterhin Embeds ohne Asset; Kontext/Sortierung/Titel wie bisher.
- **`platform_media_storage`:** Superadmin — relativer Unterpfad unter `MEDIA_ROOT`.
**Nächste Session sollte:**
**API (Auszug):**
1. Aktuelle Routen und Seiten prüfen (`App.jsx`, `EXERCISES_FRONTEND_ROUTING.md`).
2. `GET /api/exercises` (Filter, Auth) und eine Beispiel-Übung gegen die Dev-DB testen.
3. UI schrittweise: Liste → Detail → Formular → Medien (an Specs in `.claude/docs/technical/` ausrichten).
- **`GET /api/media-assets`**, Filter (Lifecycle, `media_kind`, Verein, Suche, Tags), Berechtigungen pro Zeile.
- **`PATCH /api/media-assets/{id}`** / Bulk-PATCH, **Lifecycle** (`POST …/lifecycle`, Bulk).
- **`GET …/media-assets/{id}/file`** (inkl. Kontext `ssetoken` wo vorgesehen).
- **`POST /api/exercises/{id}/media/from-asset`:** bestehendes Asset verknüpfen.
- Übungs-Uploads erzeugen/verknüpfen **`media_assets`**; Governance wie **`library_content_*`** / TenantContext.
**Frontend:**
- Route **`/media`** (Medienbibliothek): Kacheln/Liste, Filter, Copyright, Lifecycle-Actions (Rollen gemäß Spec; **`official`:** bearbeiten/Lifecycle im Wesentlichen **Superadmin**, andere Rollen **Lesemodus** im Bearbeiten-Dialog).
- Übungsformular: Archiv-Picker, Vorschau, Reaktivierung bei Dedupe-Konflikt (Papierkorb).
**Speicher & Pfade:**
- Struktur unter **`library/`** mit Vereinssegment (aus Vereinsname abgeleitet + `c{id}`), Unterordner nach Medienkind (`image`/`video`/`pdf`/`other`), Dateiname z.B. SHA-basiert — Details und Drift-Vermeidung in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**.
**Governance & Mandant:**
- **`visibility=official`** für Übungen: nur **Superadmin**; Medien **`official`:** Lifecycle/PATCH schwerpunktmäßig Superadmin (Plattform-Admin nicht gleich Superadmin).
- **Aktiver Verein:** `profiles.active_club_id`, Header **`X-Active-Club-Id`**, Response **`effective_club_id`** — nach **0.8.59** konsistent inkl. Plattform-Admin beim Reload (Backend **`tenant_context`**, Frontend **`getResolvedActiveClubIdForUi`** + Profil-Refresh nach Vereinswechsel).
---
## 5. Technische Referenz (kurz)
## 5. Geplant: Inline-Medienverlinkung (nicht umgesetzt)
**Ziel:** Mediendarstellung **innerhalb** von Fließtext-Feldern (Ablauf, Ziele, Trainerhinweise), konsistent mit derselben **`exercise_media`** bzw. Asset-Governance wie die Medienliste.
**Norm:** **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** — u.a.:
- Verweis auf **`exercise_media.id`** (oder kanonisch übersetzte Markup-Syntax), **keine** zweite Sichtbarkeitslogik.
- **Ein** zentraler Render-/Sanitize-Pfad für Übungstexte; keine verstreuten „roh `dangerouslySetInnerHTML`“-Pfade.
- XSS/CSP: nur Allowlist-HTML und kontrollierte Player-Komponenten.
**Reihenfolge:** Archiv & aktuelle Governance gelten als Basis; Inline ist die **nächste** inhaltliche Ausbaustufe für Medien (siehe **`PROJECT_STATUS.md`** Nächste Schritte).
---
## 6. Nächste Session — sinnvolle Arbeitspakete
1. **Inline §11:** Syntax festlegen (z.B. `{{exerciseMedia:id}}` → kanonisches HTML), Server normalisieren bei Speichern, einen **`renderExerciseRichText()`**-Pfad im Frontend.
2. **Tests:** pytest für `media_assets`-Router (Leserechte, Lifecycle, `from-asset`); ggf. Snapshot der Pfad-Umzug-Logik.
3. **Retention:** Job-Dokumentation + Betrieb (ENV, Intervall); Dry-Run beschreiben.
4. **S3/Adapter:** Speicher-Abstraktion §7 — wenn Produkt es verlangt.
5. **Rahmen/UI:** Kalender „aus Rahmen übernehmen“ weiter anbinden (parallel, unabhängig von Medien).
---
## 7. Technische Referenz (kurz)
| Bereich | Einstieg |
|---------|----------|
| Backend API | `backend/main.py`; Router u. a. **`training_framework_programs.py`**, **`training_planning.py`**, `maturity_models.py`, `matrix_stack_bundle.py`, `exercises.py`, `catalogs.py`, `skills.py` |
| Migrationen | `backend/migrations/` (u. a. 024027 Reifegrad/Bindings; **035037** Rahmenprogramm / SlotBlueprint) |
| Backend API | `backend/main.py`; u.a. **`media_assets.py`**, **`exercises.py`**, **`profiles.py`**, **`training_framework_programs.py`**, `tenant_context.py` |
| Migrationen | `backend/migrations/` (040+ Mitgliedschaft/Governance; **045+** Medien-Stack) |
| Frontend API | `frontend/src/utils/api.js` |
| Aktiver Verein (UI) | `frontend/src/utils/activeClub.js`, `AuthContext.jsx` |
| Version / Changelog | `backend/version.py` |
---
## 6. Veraltete Hinweise
## 8. Veraltete Hinweise
Die Datei `.claude/docs/working/HANDOVER_NEXT_SESSION.md` (2026-04-22) ist **historisch**; für den aktuellen Stand gilt **`docs/HANDOVER.md`**.
`.claude/docs/working/HANDOVER_NEXT_SESSION.md` verweist auf **dieses** Dokument (`docs/HANDOVER.md`) als aktuelle Basis.
---

View File

@ -13,9 +13,20 @@ server {
# verringert sporadische 502, wenn sich nur die Backend-Container-IP geändert hat.
resolver 127.0.0.11 valid=10s ipv6=off;
# Uploads (Übungsmedien) und API erreichen Clients unter derselben Host-URL wie die SPA
# dafür muss Nginx zur FastAPI-Instanz im Compose-Netz weiterleiten.
client_max_body_size 64m;
# Uploads (Übungsmedien, Medienarchiv-Bulk) Limit muss >= größte Einzeldatei und
# bei multipart ggf. Summe mehrerer Dateien sein (Backend praxis: bis 1024 MB Admin).
client_max_body_size 1024m;
# Medienbibliothek (React /media) vor location ^~ /media/: sonst liefert ein Reload
# auf /media/ den FastAPI StaticFiles-Mount unter /media und der Browser zeigt {"detail":"Not Found"}.
location = /media {
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 /index.html =404;
}
location = /media/ {
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 /index.html =404;
}
location ^~ /api/ {
set $docker_backend_svc backend;

View File

@ -5546,6 +5546,74 @@ a.analysis-split__nav-item {
flex: 1 1 140px;
max-width: 240px;
}
.media-library__upload-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
margin-top: 12px;
}
.media-library__upload-row .form-input {
min-width: 0;
flex: 0 1 160px;
max-width: 220px;
}
.media-library__upload-summary {
font-size: 0.85rem;
color: var(--text2);
flex: 1 1 200px;
}
.media-library__grid-top-anchor {
height: 1px;
margin: 0;
padding: 0;
overflow: hidden;
visibility: hidden;
pointer-events: none;
}
.media-library__upload-icon {
vertical-align: middle;
margin-right: 6px;
}
.media-library__sr-file {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.media-library__card-type {
position: absolute;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: var(--text2);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
/* Kachel: unten links — Checkbox liegt oben links */
.media-library__card-type--thumb-bl {
left: 6px;
bottom: 6px;
top: auto;
}
.media-library__card-type--compact {
width: 22px;
height: 22px;
left: 3px;
top: 3px;
bottom: auto;
border-radius: 6px;
}
.media-library__card-copyright {
position: absolute;
right: 6px;
@ -5797,6 +5865,7 @@ a.analysis-split__nav-item {
width: 72px;
}
.media-library__table-thumb {
position: relative;
width: 56px;
height: 56px;
border-radius: 8px;

View File

@ -1,4 +1,5 @@
import { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
/**
* Zeigt einen Vereins-Umschalter, wenn der Nutzer mehreren Vereinen zugeordnet ist.
@ -9,10 +10,7 @@ export default function ActiveClubSwitcher({ variant = 'sidebar' }) {
const clubs = user?.clubs || []
if (clubs.length <= 1) return null
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const selectClubId = getResolvedActiveClubIdForUi(user)
const isMobile = variant === 'mobile'

View File

@ -5,6 +5,7 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import ExercisePickerModal from './ExercisePickerModal'
const VIS_OPTIONS = [
@ -99,6 +100,13 @@ export default function ExerciseProgressionGraphPanel({
anchorExerciseId = null,
anchorTitle = null,
}) {
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const filteredGraphVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
)
const [graphs, setGraphs] = useState([])
const [selectedGraphId, setSelectedGraphId] = useState(null)
const [edges, setEdges] = useState([])
@ -566,7 +574,7 @@ export default function ExerciseProgressionGraphPanel({
value={newGraphVisibility}
onChange={(e) => setNewGraphVisibility(e.target.value)}
>
{VIS_OPTIONS.map((o) => (
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
@ -599,7 +607,7 @@ export default function ExerciseProgressionGraphPanel({
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select className="form-input" value={metaVisibility} onChange={(e) => setMetaVisibility(e.target.value)}>
{VIS_OPTIONS.map((o) => (
{filteredGraphVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>

View File

@ -1,5 +1,6 @@
import { Link, useLocation, useNavigate } from 'react-router-dom'
import { useAuth } from '../context/AuthContext'
import { getResolvedActiveClubIdForUi } from '../utils/activeClub'
function Navigation() {
const location = useLocation()
@ -7,10 +8,7 @@ function Navigation() {
const { user, logout, setActiveClub } = useAuth()
const clubs = user?.clubs || []
const selectClubId =
user?.active_club_id != null && clubs.some((c) => c.id === user.active_club_id)
? user.active_club_id
: clubs[0]?.id
const selectClubId = getResolvedActiveClubIdForUi(user)
const handleLogout = async () => {
await logout()

View File

@ -64,11 +64,25 @@ export function AuthProvider({ children }) {
const uid = userRef.current?.id
if (!Number.isFinite(cid) || cid < 1 || !uid) return
localStorage.setItem(ACTIVE_CLUB_STORAGE_KEY, String(cid))
setUser((prev) => (prev?.id ? { ...prev, active_club_id: cid } : prev))
setUser((prev) =>
prev?.id
? { ...prev, active_club_id: cid, effective_club_id: cid }
: prev
)
try {
await api.updateProfile(uid, { active_club_id: cid })
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
} catch (e) {
console.error(e)
try {
const profile = await api.getCurrentProfile()
syncStoredActiveClub(profile)
setUser(profile)
} catch {
/* ignore */
}
}
}, [])

View File

@ -5,6 +5,21 @@ import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/
import RichTextEditor from '../components/RichTextEditor'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
import { useAuth } from '../context/AuthContext'
/** MIME/Dateiname → Übungs-media_type; null → Dropdown-Fallback. */
function inferExerciseMediaType(file) {
if (!file) return null
const mime = (file.type || '').toLowerCase()
if (mime.startsWith('image/')) return 'image'
if (mime.startsWith('video/')) return 'video'
if (mime === 'application/pdf' || mime.includes('pdf')) return 'document'
const name = (file.name || '').toLowerCase()
if (/\.(mp4|webm|mov|mkv|avi|m4v|mpeg|mpg)$/.test(name)) return 'video'
if (/\.(jpg|jpeg|png|gif|webp|bmp|svg)$/.test(name)) return 'image'
if (/\.pdf$/.test(name)) return 'document'
return null
}
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
@ -406,6 +421,8 @@ function MultiAssocBlock({ title, rows, setRows, options, idKey, emptyLabel }) {
function ExerciseFormPage() {
const { id: routeId } = useParams()
const navigate = useNavigate()
const { user } = useAuth()
const isSuperadmin = user?.role === 'superadmin'
const exerciseId = routeId && !Number.isNaN(parseInt(routeId, 10)) ? parseInt(routeId, 10) : null
const isEdit = exerciseId != null
@ -426,7 +443,7 @@ function ExerciseFormPage() {
const [variantEditSelection, setVariantEditSelection] = useState(null)
const variantsDetailsRef = useRef(null)
const [mediaFile, setMediaFile] = useState(null)
const [mediaFiles, setMediaFiles] = useState([])
const [mediaType, setMediaType] = useState('image')
const [mediaTitle, setMediaTitle] = useState('')
const [mediaContext, setMediaContext] = useState('ablauf')
@ -676,6 +693,20 @@ function ExerciseFormPage() {
promote_attached_media_for_official: true,
...(miss > 0 ? { default_official_media_copyright: String(defaultCopyright).trim() } : {}),
})
} else if (
firstErr.status === 422 &&
firstErr.code === 'CLUB_MEDIA_COPYRIGHT_REQUIRED' &&
firstErr.payload?.media_assets
) {
alert(
'Vereinsöffentliche Übungen brauchen bei jeder verknüpften Datei einen Copyright-Vermerk (mind. 3 Zeichen). Bitte in der Medienbibliothek oder den Mediendetails nachtragen.',
)
throw firstErr
} else if (firstErr.status === 422 && firstErr.code === 'CLUB_MEDIA_LIFECYCLE') {
alert(
'Speichern nicht möglich: mindestens ein verknüpftes Medium ist nicht aktiv (Papierkorb). Bitte reaktivieren oder entfernen.',
)
throw firstErr
} else {
throw firstErr
}
@ -724,62 +755,63 @@ function ExerciseFormPage() {
)
const handleUploadFile = async () => {
if (!exerciseId || !mediaFile) {
alert('Datei wählen')
if (!exerciseId || mediaFiles.length === 0) {
alert('Datei(en) wählen')
return
}
const fd = new FormData()
fd.append('file', mediaFile)
fd.append('media_type', mediaType)
fd.append('title', mediaTitle)
fd.append('description', '')
fd.append('context', mediaContext)
fd.append('is_primary', 'false')
try {
await api.uploadExerciseMedia(exerciseId, fd)
setMediaFile(null)
setMediaTitle('')
await refreshMedia()
} catch (err) {
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))
const files = [...mediaFiles]
for (const file of files) {
const inferred = inferExerciseMediaType(file) || mediaType
const fd = new FormData()
fd.append('file', file)
fd.append('media_type', inferred)
fd.append('title', mediaTitle)
fd.append('description', '')
fd.append('context', mediaContext)
fd.append('is_primary', 'false')
try {
await api.uploadExerciseMedia(exerciseId, fd)
} catch (err) {
if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) {
const aid = err.payload.media_asset_id
const nameHint = file?.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,
})
} catch (e2) {
alert(e2.message || String(e2))
return
}
} else {
return
}
} else 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
} else {
alert(`Upload (${file.name}): ${err.message || String(err)}`)
return
}
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)))
}
setMediaFiles([])
setMediaTitle('')
await refreshMedia()
}
const handleAddEmbed = async () => {
@ -1243,7 +1275,7 @@ function ExerciseFormPage() {
>
<option value="private">Privat</option>
<option value="club">Verein</option>
<option value="official">Offiziell</option>
{isSuperadmin ? <option value="official">Offiziell</option> : null}
</select>
</div>
<div className="form-row">
@ -1457,17 +1489,26 @@ function ExerciseFormPage() {
</div>
<div style={{ display: 'grid', gap: '12px', marginTop: '12px' }}>
<div>
<label className="form-label">Datei</label>
<label className="form-label">Dateien</label>
<input
type="file"
accept="image/jpeg,image/png,image/gif,video/mp4,application/pdf"
onChange={(e) => setMediaFile(e.target.files?.[0] || null)}
multiple
accept="image/*,video/*,application/pdf"
onChange={(e) => {
setMediaFiles(Array.from(e.target.files || []))
e.target.value = ''
}}
/>
{mediaFiles.length > 0 && (
<div style={{ fontSize: '0.875rem', color: 'var(--text2)', marginTop: '6px' }}>
{mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')}
</div>
)}
<div className="form-row" style={{ marginTop: '8px' }}>
<select className="form-input" value={mediaType} onChange={(e) => setMediaType(e.target.value)}>
<option value="image">Bild</option>
<option value="video">Video</option>
<option value="document">PDF</option>
<option value="image">Typ-Fallback: Bild</option>
<option value="video">Typ-Fallback: Video</option>
<option value="document">Typ-Fallback: PDF</option>
</select>
<input
type="text"

View File

@ -152,6 +152,7 @@ function applyDashboardExerciseListUrl(mergedFromPrefs) {
function ExercisesListPage() {
const { user, checkAuth } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const [mineOnly, setMineOnly] = useState(() => {
try {
@ -557,9 +558,9 @@ function ExercisesListPage() {
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
if (isSuperadmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isPlatformAdmin])
}, [isSuperadmin])
useEffect(() => {
let cancelled = false

View File

@ -1,4 +1,4 @@
import { useEffect, useState, useCallback } from 'react'
import { useEffect, useState, useCallback, useRef, useMemo } from 'react'
import { Link } from 'react-router-dom'
import {
LayoutGrid,
@ -13,6 +13,11 @@ import {
CircleDot,
FilePenLine,
Copyright,
Image,
Video,
FileText,
File,
Upload,
} from 'lucide-react'
import { useAuth } from '../context/AuthContext'
import api from '../utils/api'
@ -148,6 +153,10 @@ function uploaderLabel(it, viewer) {
function MediaThumb({ mediaId, mimeType }) {
const url = resolveMediaAssetFileUrl(mediaId)
const mime = (mimeType || '').toLowerCase()
/* iPhone-Fotos: Browser-Vorschau oft nicht nutzbar */
if (mime.includes('heic') || mime.includes('heif')) {
return <div className="media-library__thumb-ph">HEIC</div>
}
if (mime.startsWith('video/')) {
return (
<video
@ -195,11 +204,42 @@ function previewDisplayKind(mimeType) {
return 'other'
}
const MEDIA_KIND_LABELS = {
image: 'Bild',
video: 'Video',
pdf: 'PDF',
other: 'Sonstiges',
}
function MediaTypeGlyph({ mimeType, compact }) {
const kind = previewDisplayKind(mimeType)
const label = MEDIA_KIND_LABELS[kind] || 'Medium'
let Icon = File
if (kind === 'image') Icon = Image
else if (kind === 'video') Icon = Video
else if (kind === 'pdf') Icon = FileText
const sz = compact ? 12 : 14
return (
<span
className={`media-library__card-type${compact ? ' media-library__card-type--compact' : ' media-library__card-type--thumb-bl'}`}
title={label}
aria-label={`Medientyp: ${label}`}
>
<Icon size={sz} strokeWidth={2} aria-hidden />
</span>
)
}
export default function MediaLibraryPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const isSuperadmin = user?.role === 'superadmin'
const archiveVisOptions = useMemo(
() => VIS_OPTIONS.filter((o) => o.value !== 'official' || isSuperadmin),
[isSuperadmin],
)
const [lifecycle, setLifecycle] = useState('active')
const [q, setQ] = useState('')
const [items, setItems] = useState([])
@ -222,6 +262,32 @@ export default function MediaLibraryPage() {
const [filterClubId, setFilterClubId] = useState('')
const [filterUploaderId, setFilterUploaderId] = useState('')
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
const bulkFileInputRef = useRef(null)
const [uploadVis, setUploadVis] = useState('private')
const [uploadClubId, setUploadClubId] = useState('')
const [uploadBusy, setUploadBusy] = useState(false)
const [uploadSummary, setUploadSummary] = useState('')
const mediaListFetchSeqRef = useRef(0)
const gridTopAnchorRef = useRef(null)
const modalVisibilityOptions = useMemo(() => {
if (!modalDraft) return archiveVisOptions
const o = [...archiveVisOptions]
if (!o.some((x) => x.value === modalDraft.visibility)) {
o.push({
value: modalDraft.visibility,
label: visibilityUiLabel(modalDraft.visibility),
})
}
return o
}, [archiveVisOptions, modalDraft])
useEffect(() => {
if (!isSuperadmin) return
const cid = user?.effective_club_id ?? user?.active_club_id
if (cid == null || cid === '') return
setUploadClubId(String(cid))
}, [isSuperadmin, user?.effective_club_id, user?.active_club_id])
const loadClubs = useCallback(async () => {
try {
@ -237,6 +303,7 @@ export default function MediaLibraryPage() {
}, [loadClubs])
const loadItems = useCallback(async () => {
const seq = ++mediaListFetchSeqRef.current
setLoading(true)
setError('')
try {
@ -254,6 +321,7 @@ export default function MediaLibraryPage() {
? { uploaded_by: Number(filterUploaderId) }
: {}),
})
if (seq !== mediaListFetchSeqRef.current) return
setItems(res.items || [])
setViewer(res.viewer || null)
if (res.filter_meta?.uploaders?.length) {
@ -261,9 +329,10 @@ export default function MediaLibraryPage() {
}
setSelected(new Set())
} catch (e) {
if (seq !== mediaListFetchSeqRef.current) return
setError(e.message || String(e))
} finally {
setLoading(false)
if (seq === mediaListFetchSeqRef.current) setLoading(false)
}
}, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta])
@ -329,7 +398,7 @@ export default function MediaLibraryPage() {
}
if (p.change_visibility) {
body.visibility = modalDraft.visibility
if (modalDraft.visibility === 'club') {
if (modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin)) {
const cid = Number(modalDraft.club_id)
if (!cid) {
alert('Bitte einen Verein wählen.')
@ -376,7 +445,7 @@ export default function MediaLibraryPage() {
}
if (bulkApplyVis) {
body.visibility = bulkVis
if (bulkVis === 'club') {
if (bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin)) {
const cid = Number(bulkClubId)
if (!cid) {
alert('Bitte einen Verein wählen.')
@ -428,6 +497,45 @@ export default function MediaLibraryPage() {
const selCount = selected.size
const onBulkArchiveFiles = async (e) => {
const fl = e.target.files
if (!fl?.length) return
const list = Array.from(fl)
e.target.value = ''
if (uploadVis === 'club' && !Number(uploadClubId)) {
window.alert('Bitte einen Verein für die Sichtbarkeit „Verein“ wählen.')
return
}
if (uploadVis === 'private' && isPlatformAdmin && !Number(uploadClubId)) {
window.alert('Als Plattform-Admin: Bitte den Zielverein für private Archiv-Uploads wählen (club_id).')
return
}
setUploadBusy(true)
setUploadSummary('')
try {
const res = await api.bulkUploadMediaAssets(list, {
visibility: uploadVis,
...((uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin)) && Number(uploadClubId)
? { club_id: Number(uploadClubId) }
: {}),
})
setUploadSummary(
`Archiv-Upload: neu ${res.created_count}, bereits vorhanden ${res.duplicate_count}, fehlgeschlagen ${res.failed_count}. Liste aktualisiert.`,
)
if (res.created_count > 0 || res.duplicate_count > 0) {
setFilterUploaderId('')
}
await loadItems()
window.requestAnimationFrame(() => {
gridTopAnchorRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
} catch (err) {
window.alert(err.message || String(err))
} finally {
setUploadBusy(false)
}
}
return (
<div className="app-page media-library">
{isPlatformAdmin ? <AdminPageNav /> : null}
@ -442,8 +550,9 @@ export default function MediaLibraryPage() {
</div>
</div>
<p className="media-library__intro">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner,
technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen.
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads Privat steuert nur, wer das Asset in der
Datenbank sieht; der Ablageordner folgt dem gewählten Verein wie bei Verein. Plattform-Admins wählen den
Zielverein bei privatem Archiv-Upload aktiv. Suche durchsucht Bezeichner, Speicherpfad, Copyright und Tags.
Bearbeiten über das Menü Bulk in der unteren Leiste.
</p>
</header>
@ -541,6 +650,61 @@ export default function MediaLibraryPage() {
</select>
) : null}
</div>
<div className="media-library__upload-row" aria-label="Archiv hochladen">
<input
ref={bulkFileInputRef}
type="file"
className="media-library__sr-file"
accept="image/*,video/*,application/pdf"
multiple
onChange={onBulkArchiveFiles}
/>
<button
type="button"
className="btn btn-secondary"
disabled={uploadBusy}
onClick={() => bulkFileInputRef.current?.click()}
title="Mehrere Dateien ins Archiv laden"
>
<Upload size={18} aria-hidden className="media-library__upload-icon" />
Archiv-Upload
</button>
<select
className="form-input"
value={uploadVis}
onChange={(e) => {
setUploadVis(e.target.value)
setUploadSummary('')
}}
aria-label="Sichtbarkeit für neuen Upload"
>
{archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}>
Upload: {o.label}
</option>
))}
</select>
{uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? (
<select
className="form-input"
value={uploadClubId}
onChange={(e) => setUploadClubId(e.target.value)}
aria-label="Verein für Upload"
>
<option value="">Verein wählen</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
{uploadSummary ? (
<span className="media-library__upload-summary" role="status">
{uploadSummary}
</span>
) : null}
</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} />
@ -560,6 +724,8 @@ export default function MediaLibraryPage() {
</p>
) : null}
<div ref={gridTopAnchorRef} className="media-library__grid-top-anchor" aria-hidden="true" />
{loading && !items.length ? <div className="spinner media-library__spinner" /> : null}
{!loading && !items.length && !error ? (
@ -591,6 +757,7 @@ export default function MediaLibraryPage() {
title="Vorschau"
>
<div className="media-library__card-thumb-wrap">
<MediaTypeGlyph mimeType={it.mime_type} />
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
{(it.copyright_notice || '').trim() ? (
<span
@ -658,6 +825,7 @@ export default function MediaLibraryPage() {
title="Vorschau"
>
<div className="media-library__table-thumb">
<MediaTypeGlyph mimeType={it.mime_type} compact />
<MediaThumb mediaId={it.id} mimeType={it.mime_type} />
</div>
</button>
@ -737,9 +905,22 @@ export default function MediaLibraryPage() {
{(() => {
const url = resolveMediaAssetFileUrl(preview.id)
const kind = previewDisplayKind(preview.mime_type)
const mlow = (preview.mime_type || '').toLowerCase()
if (!url) {
return <p className="media-library__hint">Keine Datei-URL.</p>
}
if (mlow.includes('heic') || mlow.includes('heif')) {
return (
<div className="media-library__preview-fallback">
<p className="media-library__hint">
HEIC/HEIF in diesem Browser oft keine eingebettete Vorschau. Zum Ansehen herunterladen oder im neuen Tab öffnen.
</p>
<a className="btn btn-secondary" href={url} target="_blank" rel="noopener noreferrer">
Datei öffnen
</a>
</div>
)
}
if (kind === 'image') {
return (
<img
@ -819,13 +1000,13 @@ export default function MediaLibraryPage() {
{bulkApplyVis ? (
<>
<select className="form-input" value={bulkVis} onChange={(e) => setBulkVis(e.target.value)}>
{VIS_OPTIONS.map((o) => (
{archiveVisOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{bulkVis === 'club' ? (
{bulkVis === 'club' || (bulkVis === 'private' && isPlatformAdmin) ? (
<select className="form-input" value={bulkClubId} onChange={(e) => setBulkClubId(e.target.value)}>
<option value=""> Verein </option>
{clubs.map((c) => (
@ -875,13 +1056,20 @@ export default function MediaLibraryPage() {
<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>
<h2 id="edit-media-title">
Medium #{modal.id}
{(modal.visibility || '').toLowerCase() === 'official' && !isSuperadmin
? ' · Nur Lesen'
: ''}
</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) && (
{(viewer?.show_club_meta ||
viewer?.show_uploader_meta ||
(modal.visibility || '').toLowerCase() === 'official') && (
<div className="media-library__meta-block">
{viewer?.show_club_meta ? (
<div>
@ -928,6 +1116,18 @@ export default function MediaLibraryPage() {
placeholder="z. B. Technik, Wurf"
/>
</>
) : (modal.visibility || '').toLowerCase() === 'official' ? (
<>
<p className="media-library__hint">
Offizielle Medien sind für alle sichtbar. Bearbeiten, Sichtbarkeit und Löschung nur als Superadmin.
</p>
<label className="form-label">Bezeichnung</label>
<input className="form-input" readOnly value={modalDraft.display_name} />
<label className="form-label">Copyright</label>
<textarea className="form-input" readOnly rows={3} value={modalDraft.copyright_notice} />
<label className="form-label">Schlagwörter</label>
<input className="form-input" readOnly value={modalDraft.tags_input} />
</>
) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p>
)}
@ -940,13 +1140,13 @@ export default function MediaLibraryPage() {
value={modalDraft.visibility}
onChange={(e) => setModalDraft((d) => ({ ...d, visibility: e.target.value }))}
>
{VIS_OPTIONS.map((o) => (
{modalVisibilityOptions.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{modalDraft.visibility === 'club' ? (
{modalDraft.visibility === 'club' || (modalDraft.visibility === 'private' && isPlatformAdmin) ? (
<>
<label className="form-label">Verein</label>
<select
@ -981,9 +1181,11 @@ export default function MediaLibraryPage() {
</div>
<div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
{modal.permissions?.edit_metadata ? (
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern
</button>
) : null}
</div>
<div className="media-library__lc-block">

View File

@ -0,0 +1,32 @@
import { ACTIVE_CLUB_STORAGE_KEY } from './api'
/**
* Einheitliche Anzeige des aktiven Vereins: Abgleich mit effective_club_id, active_club_id,
* LocalStorage (Request-Header-Quelle), sonst erster Verein der Liste.
*/
export function getResolvedActiveClubIdForUi(user) {
const clubs = user?.clubs || []
if (!clubs.length) return null
const idInClubs = (id) =>
id != null &&
id !== '' &&
clubs.some((c) => Number(c.id) === Number(id))
const eff = user?.effective_club_id
if (idInClubs(eff)) return Number(eff)
const ac = user?.active_club_id
if (idInClubs(ac)) return Number(ac)
try {
const ls = localStorage.getItem(ACTIVE_CLUB_STORAGE_KEY)
if (ls && /^\d+$/.test(ls.trim()) && clubs.some((c) => String(c.id) === ls.trim())) {
return Number(ls.trim())
}
} catch {
/* ignore */
}
return Number(clubs[0].id)
}

View File

@ -464,7 +464,7 @@ export function buildExerciseApiPayload(formData, extras = {}) {
export async function uploadExerciseMedia(exerciseId, formData) {
const token = localStorage.getItem('authToken')
const headers = {}
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const response = await fetch(`${API_URL}/api/exercises/${exerciseId}/media`, {
method: 'POST',
@ -472,8 +472,14 @@ export async function uploadExerciseMedia(exerciseId, formData) {
body: formData,
})
if (!response.ok) {
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
const d = err.detail
const text = await response.text()
let parsed = null
try {
parsed = text ? JSON.parse(text) : null
} catch {
parsed = null
}
const d = parsed?.detail
if (
response.status === 409 &&
d &&
@ -489,6 +495,14 @@ export async function uploadExerciseMedia(exerciseId, formData) {
e.payload = d
throw e
}
if (response.status === 413) {
const nginx = (text || '').toLowerCase().includes('nginx')
throw new Error(
nginx
? 'Die Anfrage ist zu groß (413). Häufig: nginx „client_max_body_size“ — z. B. große/r mehrere Videos oder Bulk-Upload. Dateien kleiner aufteilen oder Server-Limit erhöhen (Frontend-Image Neu bauen).'
: 'Die Anfrage ist zu groß (413). Dateigröße oder Server-Limit prüfen.',
)
}
const msg =
typeof d === 'string'
? d
@ -496,7 +510,9 @@ export async function uploadExerciseMedia(exerciseId, formData) {
? d.message
: d != null
? JSON.stringify(d)
: `HTTP ${response.status}`
: text && text.length < 400 && !/^\s*</.test(text)
? `HTTP ${response.status}: ${text.trim()}`
: `HTTP ${response.status}`
throw new Error(msg)
}
return response.json()
@ -564,6 +580,49 @@ export async function bulkPatchMediaAssets(data) {
})
}
/**
* Mehrere Dateien ins Medienarchiv (`POST /api/media-assets/bulk-upload`).
* @param {File[]} files
* @param {{ visibility?: string, club_id?: number }} [options]
*/
export async function bulkUploadMediaAssets(files, options = {}) {
const visibility = options.visibility || 'private'
const token = localStorage.getItem('authToken')
const headers = mergeActiveClubHeader({})
if (token) headers['X-Auth-Token'] = token
const formData = new FormData()
formData.append('visibility', String(visibility))
if (options.club_id != null && options.club_id !== '') {
formData.append('club_id', String(options.club_id))
}
const arr = Array.isArray(files) ? files : [files]
for (const f of arr) {
if (f) formData.append('files', f)
}
const url = `${API_URL}/api/media-assets/bulk-upload`
const response = await fetch(url, {
method: 'POST',
headers,
body: formData,
})
if (!response.ok) {
const text = await response.text()
let parsed = null
try {
parsed = JSON.parse(text)
} catch {
parsed = null
}
if (parsed?.detail != null) {
const d = parsed.detail
throw new Error(typeof d === 'string' ? d : JSON.stringify(d))
}
const snippet = (text || '').replace(/\s+/g, ' ').trim().slice(0, 180)
throw new Error(snippet ? `HTTP ${response.status}: ${snippet}` : `HTTP ${response.status}`)
}
return response.json()
}
/** Ü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`, {
@ -1313,6 +1372,7 @@ export const api = {
patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
bulkUploadMediaAssets,
attachExerciseMediaFromAsset,
listExerciseProgressionGraphs,
getExerciseProgressionGraph,