Merge pull request 'MediaPfad extern, Upload Manager Bug Fixes' (#23) from develop into main
All checks were successful
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
All checks were successful
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 23s
Deploy Production / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 25s
Reviewed-on: #23
This commit is contained in:
commit
f035b5bb0b
|
|
@ -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** (036–037), **Progressionsgraph** (032–034) — 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** | ✅ | 🔲 |
|
||||
| **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
|
||||
| **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 |
|
||||
| **040–045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
|
||||
| **040–046** | **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. 036–037) |
|
||||
| Lieferliste Q2 2026 | `library/FEATURES_DELIVERED_2026-Q2.md` | 2026-05-07 | ✅ Aktualisiert (§12 Medien 0.8.41–0.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 040–046 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)
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
# Shinkan Jinkendo - Fachliches Domänenmodell
|
||||
|
||||
**Version:** 0.4.3
|
||||
**Stand:** 2026-05-05 (Migration **036–037:** Rahmen nur Bibliothek; Slot‑Inhalt ü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. SHA‑Dedupe, 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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. 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**. Trainingsrahmen‑Bibliothek + Slot‑Blueprint: **`technical/TRAINING_FRAMEWORK_SPEC.md`** §2. **Progressionsgraph zwischen Übungen** (Zwischenstand, Grenzen): **§§3–4**. **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`** |
|
||||
| **045–046** | **`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.41–0.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: Kalender‑UI‑Anbindung **„aus Rahmen übernehmen“**; Visibility/Policies für geteilte Rahmen (**CURR‑004** 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` |
|
||||
|
|
|
|||
|
|
@ -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** (Rahmen‑Slot‑Blueprints 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** (Rahmen‑Slot‑Blueprints) 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** | **Slot‑Blueprint:** `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`** | ✅ |
|
||||
|
||||
---
|
||||
| **040–046** | **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 **035–036**)
|
||||
|
|
|
|||
|
|
@ -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.47–0.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.42–0.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 1–2 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.1–11.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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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.*
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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**, DB‑Schema‑Version **`20260505037`**; Kern: Übungen, Varianten, Medien, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — Details `PROJECT_STATUS.md` und `TRAINING_FRAMEWORK_SPEC.md` §2.
|
||||
Kurz (Stand 2026-05-07): App **0.8.59**, DB‑Schema‑Version siehe **`backend/version.py`**; Kern: Übungen, Varianten, **Medien-Archiv & Bibliothek (`/media`)**, Mandanten-Sync aktiver Verein, Planung mit Sektionen, **Trainingsrahmen Bibliothek + Slot‑Blueprint** (036–037), Progressionsgraph, Reifegrad/Matrix‑Stack — 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**), Slot‑Ablauf = `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`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
62
backend/tests/test_club_exercise_media_copyright.py
Normal file
62
backend/tests/test_club_exercise_media_copyright.py
Normal 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
|
||||
107
backend/tests/test_library_storage_key.py
Normal file
107
backend/tests/test_library_storage_key.py
Normal 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"
|
||||
)
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
109
docs/HANDOVER.md
109
docs/HANDOVER.md
|
|
@ -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 & Planungs‑Blueprint (kurz)
|
||||
|
||||
- **Migration 036:** Rahmenkopf nur Bibliothek (Kontext: Fokusbereich, Stilrichtung; M:N Trainingsarten/Zielgruppen); keine `plan_mode`/keine Kopf‑`group_id`.
|
||||
- **Migration 037:** Pro Rahmen‑Slot 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. 024–027 Reifegrad/Bindings; **035–037** Rahmenprogramm / Slot‑Blueprint) |
|
||||
| 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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 */
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
32
frontend/src/utils/activeClub.js
Normal file
32
frontend/src/utils/activeClub.js
Normal 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)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user