diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index c0333bc..0087c04 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -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) diff --git a/.claude/docs/functional/DOMAIN_MODEL.md b/.claude/docs/functional/DOMAIN_MODEL.md index fa96c38..a7179f7 100644 --- a/.claude/docs/functional/DOMAIN_MODEL.md +++ b/.claude/docs/functional/DOMAIN_MODEL.md @@ -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. diff --git a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md index 73e0c34..01d05b0 100644 --- a/.claude/docs/functional/SHINKAN_REQUIREMENTS.md +++ b/.claude/docs/functional/SHINKAN_REQUIREMENTS.md @@ -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. diff --git a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md index 90045d9..8ae50f6 100644 --- a/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md +++ b/.claude/docs/library/FEATURES_DELIVERED_2026-Q2.md @@ -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 (`
`), **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` | diff --git a/.claude/docs/technical/DATABASE_SCHEMA.md b/.claude/docs/technical/DATABASE_SCHEMA.md index 58a3668..2531f54 100644 --- a/.claude/docs/technical/DATABASE_SCHEMA.md +++ b/.claude/docs/technical/DATABASE_SCHEMA.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**) diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md index 5aa91d8..4ca0d1d 100644 --- a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md +++ b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md @@ -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) diff --git a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md index 3707e94..397e4c0 100644 --- a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md +++ b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md @@ -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). diff --git a/.claude/docs/working/HANDOVER_NEXT_SESSION.md b/.claude/docs/working/HANDOVER_NEXT_SESSION.md index 2597020..bb50075 100644 --- a/.claude/docs/working/HANDOVER_NEXT_SESSION.md +++ b/.claude/docs/working/HANDOVER_NEXT_SESSION.md @@ -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.* diff --git a/.claude/rules/ARCHITECTURE.md b/.claude/rules/ARCHITECTURE.md index 7a7c853..f3559b9 100644 --- a/.claude/rules/ARCHITECTURE.md +++ b/.claude/rules/ARCHITECTURE.md @@ -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: diff --git a/.claude/rules/DOCUMENTATION.md b/.claude/rules/DOCUMENTATION.md index f760324..3de2325 100644 --- a/.claude/rules/DOCUMENTATION.md +++ b/.claude/rules/DOCUMENTATION.md @@ -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) --- diff --git a/.env.example b/.env.example index cce781b..31f5813 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index a69a599..10ee6fb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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`. diff --git a/README.md b/README.md index f49ed9d..0526a88 100644 --- a/README.md +++ b/README.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 diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 10a6135..d665dd4 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -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: diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py index 570b4f4..e2d273a 100644 --- a/backend/media_lifecycle.py +++ b/backend/media_lifecycle.py @@ -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") diff --git a/backend/media_storage.py b/backend/media_storage.py index f24a6a4..304b738 100644 --- a/backend/media_storage.py +++ b/backend/media_storage.py @@ -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)) diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index ba7f919..22865e5 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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, ), diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index 4378131..7efd304 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -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) diff --git a/backend/tenant_context.py b/backend/tenant_context.py index aac56e0..5bb970a 100644 --- a/backend/tenant_context.py +++ b/backend/tenant_context.py @@ -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( diff --git a/backend/tests/test_access_layer.py b/backend/tests/test_access_layer.py index 11654b3..ba8a628 100644 --- a/backend/tests/test_access_layer.py +++ b/backend/tests/test_access_layer.py @@ -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 diff --git a/backend/tests/test_club_exercise_media_copyright.py b/backend/tests/test_club_exercise_media_copyright.py new file mode 100644 index 0000000..f322849 --- /dev/null +++ b/backend/tests/test_club_exercise_media_copyright.py @@ -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 diff --git a/backend/tests/test_library_storage_key.py b/backend/tests/test_library_storage_key.py new file mode 100644 index 0000000..693f752 --- /dev/null +++ b/backend/tests/test_library_storage_key.py @@ -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" + ) diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index fac7839..03398fd 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -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, diff --git a/backend/version.py b/backend/version.py index 317abc7..2b819a6 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/docker-compose.dev-env.yml b/docker-compose.dev-env.yml index 231da53..f41e39e 100644 --- a/docker-compose.dev-env.yml +++ b/docker-compose.dev-env.yml @@ -1,5 +1,6 @@ # Keine festen container_name — Compose-Namen haben Projektprefix (-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: diff --git a/docker-compose.yml b/docker-compose.yml index b41c62e..4231662 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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: diff --git a/docs/HANDOVER.md b/docs/HANDOVER.md index 8ffbc8c..1be7643 100644 --- a/docs/HANDOVER.md +++ b/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. --- diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 4cf0529..efe13dc 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -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; diff --git a/frontend/src/app.css b/frontend/src/app.css index cd7d141..bcd4fad 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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; diff --git a/frontend/src/components/ActiveClubSwitcher.jsx b/frontend/src/components/ActiveClubSwitcher.jsx index 0243b79..489eb24 100644 --- a/frontend/src/components/ActiveClubSwitcher.jsx +++ b/frontend/src/components/ActiveClubSwitcher.jsx @@ -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' diff --git a/frontend/src/components/ExerciseProgressionGraphPanel.jsx b/frontend/src/components/ExerciseProgressionGraphPanel.jsx index 5314bc2..f9ee3fd 100644 --- a/frontend/src/components/ExerciseProgressionGraphPanel.jsx +++ b/frontend/src/components/ExerciseProgressionGraphPanel.jsx @@ -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) => ( @@ -599,7 +607,7 @@ export default function ExerciseProgressionGraphPanel({
@@ -1457,17 +1489,26 @@ function ExerciseFormPage() {
- + 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 && ( +
+ {mediaFiles.length} Datei(en): {mediaFiles.map((f) => f.name).join(', ')} +
+ )}
{ 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 diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 385d01d..8c39e0a 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -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
HEIC
+ } if (mime.startsWith('video/')) { return (

- 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.

@@ -541,6 +650,61 @@ export default function MediaLibraryPage() { ) : null}
+
+ + + + {uploadVis === 'club' || (uploadVis === 'private' && isPlatformAdmin) ? ( + + ) : null} + {uploadSummary ? ( + + {uploadSummary} + + ) : null} +