feat: enhance media management and governance in the project
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 27s
All checks were successful
Deploy Development / deploy (push) Successful in 35s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Successful in 27s
- Added new documentation for media assets and lifecycle management, establishing a single source of truth in MEDIA_ASSETS_AND_ARCHIVE_SPEC.md. - Updated project status to reflect the addition of media archive and lifecycle governance. - Introduced a new API endpoint for platform media storage, allowing superadmin access for media management. - Enhanced exercise media handling with improved database integration for media assets, including deduplication and effective media root resolution. - Updated frontend API utilities to support new media storage functionalities, ensuring seamless integration with the backend. - Incremented version to 0.8.41, reflecting the latest changes and improvements in media handling.
This commit is contained in:
parent
161d520329
commit
7284c577d7
|
|
@ -146,7 +146,8 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
| Frontend Routing | `technical/EXERCISES_FRONTEND_ROUTING.md` | 2026-04-30 | ✅ Ergänzt UI-Hinweise |
|
||||
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
|
||||
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-05 | ✅ Diese Datei |
|
||||
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ Single Source of Truth |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -157,4 +158,4 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-05
|
||||
**Letzte Aktualisierung:** 2026-05-07
|
||||
|
|
|
|||
|
|
@ -118,8 +118,9 @@ Ausgangslage im Code: `private` \| `club` \| `official` (siehe `club_tenancy`).
|
|||
## 7. Referenzen
|
||||
|
||||
- `.claude/docs/technical/MULTI_TENANCY_RBAC_ARCHITECTURE.md` – übergeordnetes Zielbild & Begriffe.
|
||||
- `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` – verbindliche Domänenregeln für **Medien-Assets** (gleiche Sichtbarkeit wie Übungen, Promotion-Kopplung, Copyright, Papierkorb/Lebenszyklus, externer Speicher). Bei Widerspruch zur Sichtbarkeits-Tabelle in §3 dieses Dokuments: §3 für Enums/`library_content_*`-Semantik, Medien-Spez für Asset-spezifische Zusatzregeln.
|
||||
- `backend/club_tenancy.py` – bestehende Bausteine (`assert_club_member`, `exercise_visible_to_profile`, …); Ziel ist Deren schrittweise Zusammenführung unter die neue Zugriffsschicht ohne Big-Bang.
|
||||
|
||||
---
|
||||
|
||||
**Letzte Aktualisierung:** 2026-05-06
|
||||
**Letzte Aktualisierung:** 2026-05-07
|
||||
|
|
|
|||
198
.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
Normal file
198
.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
# Medien-Assets, Archiv & Lebenszyklus (Single Source of Truth)
|
||||
|
||||
**Status:** verbindlich für Design, API und DB-Migrationen zum Thema Medien
|
||||
**Stand:** 2026-05-07
|
||||
**ersetzt/ergänzt:** operatives Upload-Format/Größen siehe weiterhin `MEDIA_UPLOAD_SPEC.md`
|
||||
**normative Governance:** Sichtbarkeit & Mandanten wie `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`; bei Abweichung zwischen Dokumenten hat der Zugriffsplan Vorrang, **außer** dieses Dokument präzisiert explizit nur den Medien-Domain.
|
||||
|
||||
---
|
||||
|
||||
## 1. Zweck und Abgrenzung
|
||||
|
||||
### 1.1 Zweck
|
||||
|
||||
- **Ein physisches Medium** („Datei-Asset“) wird **einmal** gespeichert und **mehrfach** mit Übungen verknüpft (Wiederverwendung, keine Dubletten auf der Platte).
|
||||
- **Sichtbarkeit** von Datei-Assets folgt **derselben Semantik** wie bei Übungen (`private` \| `club` \| `official` – ggf. spätere Enum-Erweiterungen nur gemeinsam mit Migration + ACCESS_LAYER-Doku).
|
||||
- **Superadmin** kann Medien **global** freigeben (fachlich: Stufe wie „offiziell“ / plattformweit lesbar gemäß `library_content_*`-Regeln).
|
||||
- **Copyright:** pro Asset sind **Vermerke** vorgesehen und an `official` / Vereinsnutzung anzubinden (Validierungsstufen in Umsetzung festlegen).
|
||||
- **Externer Speicher:** Speicherung darf **lokal/NAS** (Filesystem) oder **extern** (z. B. S3-kompatibler Dienst) erfolgen – die App arbeitet über eine **Speicher-Abstraktion** (Interface), nicht mit verstreuten `Path`-Annahmen.
|
||||
- **Papierkorb & Retention:** mehrstufiger Lebenszyklus mit **automatischen** und **manuellen** Übergängen, siehe §5.
|
||||
|
||||
### 1.2 Abgrenzung
|
||||
|
||||
- **Embeds** (reine externe URL, kein Upload) bleiben pro **Verknüpfung zur Übung** möglich (`embed_url` o. Ä.); sie haben **keinen** physischen Lebenszyklus wie Datei-Assets (kein Papierkorb „Stufe 3 physische Löschung“). Trotzdem: UI kann „Link ungültig“ o. Ä. separat abbilden.
|
||||
- **Übungs-Governance** (wer eine Übung bearbeiten darf) **bleibt maßgeblich** für **Anlegen/Entfernen/Umsortieren** von Medien **in dieser Übung**; Medien erzwingen **keine** schärfere Edit-Policy als die Übung selbst.
|
||||
|
||||
---
|
||||
|
||||
## 2. Begriffe
|
||||
|
||||
| Begriff | Bedeutung |
|
||||
|--------|-----------|
|
||||
| **Media Asset** | Logische Entität für eine **hochgeladene Datei** inkl. Metadaten, Sichtbarkeit, Copyright, Speicherreferenz, Lifecycle-Status. |
|
||||
| **Übungs-Medium / Attachment** | Verknüpfung **Übung ↔ Asset** oder reines Embed; enthält u. a. `context` (Sektion), `sort_order`, übungsspezifischen Titel/Beschreibung, `is_primary`. |
|
||||
| **Medienmanager / Archiv** | Verwaltungs-UI/API für Assets (Suche, Metadaten, Lifecycle, ggf. Superadmin-global). |
|
||||
|
||||
---
|
||||
|
||||
## 3. Zieldatenmodell (Überblick)
|
||||
|
||||
> Konkrete Tabellennamen/Migrationen beim Implementierungsstart festziehen; dieses Kapitel ist die **fachliche Norm**.
|
||||
|
||||
### 3.1 Tabelle `media_assets` (oder gleichwertiger Name)
|
||||
|
||||
**Pflichtidee:**
|
||||
|
||||
- Identität, technische Metadaten: `id`, `mime_type`, `byte_size`, `sha256` (vollständig), `original_filename`, `created_at`, `updated_at`.
|
||||
- Mandant & Governance: `visibility`, `club_id` (nullable je nach Semantik wie bei Übungen), `uploaded_by_profile_id`.
|
||||
- Copyright: mindestens `copyright_notice` (TEXT); optional `license`, `attribution`, `source_url`.
|
||||
- Speicher: `storage_backend` (Enum/String: z. B. `local`, `s3`, …), `storage_key` (relativer oder bucket-key), optional `storage_url` nur für interne Zwecke – **keine** öffentlichen Secrets in der DB.
|
||||
- Lifecycle: `lifecycle_state` (siehe §5), Timestamps für Übergänge und geplante Purge-Termine.
|
||||
|
||||
**Deduplizierung:** Innerhalb sinnvoller Grenze (z. B. **pro Verein** bei `club`, global nur mit Superadmin-Policy) über `sha256` + `club_id`/`visibility`; Konflikt bei Upload → bestehendes Asset verknüpfen oder expliziter Nutzerdialog (Umsetzungsdetail).
|
||||
|
||||
### 3.2 Verknüpfung (heute `exercise_media`)
|
||||
|
||||
- Erweiterung um **`media_asset_id`** (FK, nullable wenn reines Embed).
|
||||
- Embed-Zeilen: `media_asset_id IS NULL`, `embed_url`/`embed_platform` gesetzt wie heute.
|
||||
- Übungsspezifische Felder: `context` (`ablauf` \| `detail` \| `trainer_hint`), `sort_order`, `title`, `description`, `is_primary`.
|
||||
|
||||
### 3.3 Referenzen & physisches Löschen
|
||||
|
||||
- Vor **physischem** Löschen muss die Referenzanzahl aller aktiven/nicht-purged Links **0** sein (oder Quarantäne-Policy); siehe §5.3.
|
||||
|
||||
---
|
||||
|
||||
## 4. Sichtbarkeit, Lesen und Übungs-Promotion
|
||||
|
||||
### 4.1 Leseregel Datei-Asset
|
||||
|
||||
- Zentrale Entscheidung analog `library_content_visible_to_profile` / TenantContext: **Profil darf Asset lesen** nur wenn Visibility+`club_id`+`created_by` zum Objekt passen (gleiche Philosophie wie Übungen).
|
||||
- **GET Download / Stream:** muss **Asset-Governance** prüfen; wenn der Aufruf **im Kontext einer Übung** erfolgt, zusätzlich (oder als Schnittmenge) **Übung lesbar** – verbindlich **ohne Seitenkanal** (kein Download nur mit Übungs-ID, wenn Asset für Nutzer unsichtbar wäre).
|
||||
|
||||
### 4.2 Promotion Übung → `official` (bzw. global)
|
||||
|
||||
Beim Speichern/Promoten einer Übung auf **`official`** (oder vergleichbare globale Stufe):
|
||||
|
||||
1. **Pflicht-Dialog:** Hinweis, dass **alle zugeordneten Datei-Assets**, die noch **nicht** diese Sichtbarkeit haben, **mit angehoben** werden (oder die Aktion schlägt fehl / erfordert Anpassung).
|
||||
2. **Abbruch:** Nutzer kann abbrechen oder einzelne Assets vor Freigabe anpassen / entkoppeln.
|
||||
3. **Copyright:** Für `official` sind leere oder unzureichende Copyright-Felder **nicht** akzeptabel (genaue Regel in Umsetzung + Validierung).
|
||||
|
||||
Superadmin kann Medien **explizit** global/offiziell machen (Archiv-Pfad), unabhängig von einer einzelnen Übung – konsistent zur Rolle.
|
||||
|
||||
### 4.3 Bearbeitung Medien in der Übung
|
||||
|
||||
Wer die Übung **bearbeiten** darf (bestehende oder künftig erweiterte Regel), darf für diese Übung:
|
||||
|
||||
- Medien **hinzufügen** (Upload neu oder Verknüpfung aus Archiv),
|
||||
- **Reihenfolge** / **Sektion** / **Titel** ändern,
|
||||
- **Verknüpfung** entfernen (Asset bleibt im Archiv, sofern nicht anderweitig gelöscht),
|
||||
- Embeds pflegen.
|
||||
|
||||
Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **global** zu Trash/ Purge zu bewegen, darf trotzdem die **Verknüpfung** in „seiner“ Übung lösen, solange die Übungs-Edit-Policy das erlaubt.
|
||||
|
||||
---
|
||||
|
||||
## 5. Lebenszyklus (Papierkorb) – Norm
|
||||
|
||||
### 5.1 Zustände
|
||||
|
||||
| Zustand | Code (Beispiel) | Neue Zuordnung zu Übungen | Anzeige in Übung (Lesemodus) | Anzeige Bearbeiten |
|
||||
|---------|-----------------|---------------------------|------------------------------|-------------------|
|
||||
| Aktiv | `active` | ja | normal | normal |
|
||||
| Stufe 1 – Papierkorb | `trash_soft` | **nein** | ja + **Warnhinweis** („wird abgeschafft“) | ja + Warnhinweis |
|
||||
| Stufe 2 – Ausblendung | `trash_hidden` | nein | **nein** (Platzhalter/„nicht verfügbar“) | Recovery-Aktion sichtbar |
|
||||
| Stufe 3 – purgiert | `purged` / Zeile entfernt | nein | nein | nur noch historischer Hinweis nach Policy |
|
||||
|
||||
**Default-Retention (automatisch):**
|
||||
|
||||
- Stufe 1 → Stufe 2: **ca. 30 Tage** nach Eintritt in Stufe 1 (konfigurierbar).
|
||||
- Stufe 2 → Stufe 3 **physisches Löschen**: **ca. 90 Tage** (3 Monate) nach Eintritt in Stufe 2 (konfigurierbar).
|
||||
|
||||
**Manuell:** Berechtigte Rollen dürfen Übergänge **vorziehen** (Stufe 1 erzwingen, Stufe 2 erzwingen, Purge erzwingen) gemäß §5.2 – ggf. **Tier-Gates** (§6).
|
||||
|
||||
### 5.2 Wer darf Lifecycle-Transitionen?
|
||||
|
||||
| Aktion | Vereins-Asset (`club`, Owner-Verein) | Privates Asset (`private`, Uploader) | `official` / plattformweit |
|
||||
|--------|--------------------------------------|--------------------------------------|-----------------------------|
|
||||
| Stufe 1 (Papierkorb) | **Vereinsadmin** + **Superadmin** | **Uploader** + **Superadmin** | **Superadmin** |
|
||||
| Stufe 2 vorziehen / erzwingen | Vereinsadmin + Superadmin | Uploader + Superadmin | Superadmin |
|
||||
| Recovery Stufe 2 → 1 | wie Stufe 1-Eintritt, ggf. Tier | wie links | Superadmin |
|
||||
| Physisches Löschen (3) | Vereinsadmin + Superadmin; Systemjob nach Frist | Uploader + Superadmin | Superadmin |
|
||||
|
||||
**Superadmin / Systemadmin** ist immer **Override** (Klarstellung zur „systemadmin“-Formulierung).
|
||||
|
||||
### 5.3 Konsistenz mit bestehenden Übungen
|
||||
|
||||
- In **Stufe 1** bleiben Verknüpfungen funktional für Anzeige bestehen; Nutzer sehen **Warnung**.
|
||||
- In **Stufe 2** wird das Medium in der **öffentlichen Übungsansicht nicht gerendert**; im **Bearbeitungsmodus** gibt es einen **Recovery-Link**, der das Asset zurück auf **Stufe 1** setzt (Policy: nur wer nach §5.2 Stufe 1 setzen darf, oder erweiterte Regel – bei Konflikt ACCESS_LAYER + dieses Dokument anpassen).
|
||||
- **Physisches Löschen:** nur nach Stufe 2-Frist oder manueller Purge-Aktion; Backend entfernt Datei über Speicher-Backend und markiert Asset endgültig.
|
||||
|
||||
### 5.4 Hintergrundjobs
|
||||
|
||||
- Geplanter Job (täglich o. Ä.): Transitionen 1→2 und 2→3 gemäß Timestamps.
|
||||
- Alle Zeiten **konfigurierbar** (Umgebungsvariablen oder DB-Config), Defaults wie §5.1.
|
||||
|
||||
---
|
||||
|
||||
## 6. Tier & Features
|
||||
|
||||
- **Tier** kann erlauben oder verbieten: **manuelles** Vorziehen von Stufe 2 oder Purge, zusätzliche Speicherquoten, externes Backend, etc.
|
||||
- Tier ändert **nicht** die Übungs-Sichtbarkeit an sich; es **limitiert** nur Medien-spezifische Aktionen (Löschstufen, Archiv-Größe, …). Konkrete Feature-Keys in Umsetzung in `features` / `tier_limits` ergänzen und hier im **Changelog** dieses Dokuments verlinken.
|
||||
|
||||
---
|
||||
|
||||
## 7. Externe Speicherung (Server / S3)
|
||||
|
||||
- Abstraktion **StorageAdapter**: `put`, `get`, `delete`, ggf. `exists`.
|
||||
- Konfiguration über ENV (z. B. Endpoint, Bucket, Credentials) – nie im Frontend.
|
||||
- **NAS / anderer Rechner als App-Host:** In der Praxis `MEDIA_ROOT` in Compose/ENV auf den **Mount-Punkt** des NAS (oder NFS/SMB) setzen – die App läuft z. B. auf dem Raspberry, die Bytes liegen auf dem Speichersystem. Kein »Mediaserver« mit eigener Geschäftslogik nötig.
|
||||
- **Pfadkonvention** auf der Platte z. B. `clubs/{club_id}/…` für Mandantentrennung (Umsetzung schrittweise; neue Uploads können zuerst unter `exercises/{sha256}{ext}` liegen).
|
||||
|
||||
### 7.1 Konfiguration: Bootstrap vs. Superadmin (Laufzeit)
|
||||
|
||||
| Ebene | Inhalt | Wer / Wo |
|
||||
|-------|--------|----------|
|
||||
| **Bootstrap** | Basis-Verzeichnis `MEDIA_ROOT` (Container/Host), ggf. S3-Secrets | Deployment (`.env`, Docker Compose) – Container muss ohne UI starten können |
|
||||
| **Laufzeit (nicht-geheim)** | Zusätzlicher **relativer Unterpfad** unter `MEDIA_ROOT` (`local_relative_root`), später: aktives Backend `local` \| `s3`, öffentlicher Endpoint/Bucket-Name | **Superadmin** über Tabelle `platform_media_storage` + API; keine Secrets für S3 im Klartext in der DB (Keys nur ENV/Vault) |
|
||||
| **Effektives Wurzelverzeichnis** | `Path(MEDIA_ROOT) / local_relative_root` nach Normalisierung (kein `..`, kein absoluter Pfad im relativen Segment) | berechnet im Backend bei jedem Zugriff |
|
||||
|
||||
**Hinweis Beta:** Primär ein Verein, Videos auf separatem physischen System → typischerweise **ein NAS-Mount** als `MEDIA_ROOT`; Superadmin kann bei Bedarf einen **Unterordner** setzen, ohne neues Image zu bauen.
|
||||
|
||||
**Drift:** Jede Änderung an Speicher-Konfiguration oder Asset-Schema → Migration + Eintrag **§10 Changelog** + bei neuen Endpoints **ACCESS_LAYER_ENDPOINT_AUDIT.md**.
|
||||
|
||||
---
|
||||
|
||||
## 8. Embeds & Streaming-Plattformen
|
||||
|
||||
- Embeds **bleiben** erste Klasse; bestehende Plattform-Erkennung erweiterbar (`embed_platform`).
|
||||
- Roadmap: weitere Hosts (Player-Komponente, oEmbed, CSP) – **ohne** Änderung am Asset-Lifecycle-Modell.
|
||||
|
||||
---
|
||||
|
||||
## 9. Pflichten zur Drift-Vermeidung
|
||||
|
||||
| Änderung an … | Pflegepflicht |
|
||||
|---------------|---------------|
|
||||
| DB-Schema Medien | Neue Migration + **Abschnitt/Changelog in diesem Dokument** (Datum, Kurzbeschreibung) |
|
||||
| Sichtbarkeit / Enums | Zuerst `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`, dann dieses Dokument synchron halten |
|
||||
| Neue Endpoints Medien/Archiv | `ACCESS_LAYER_ENDPOINT_AUDIT.md` + `get_tenant_context`/Governance |
|
||||
| Implementierungs-Meilenstein | `backend/version.py` (`MODULE_VERSIONS` / Schema-Kommentar) und bei größerem Release `PROJECT_STATUS.md` |
|
||||
|
||||
---
|
||||
|
||||
## 10. Changelog
|
||||
|
||||
| Datum | Änderung |
|
||||
|-------|----------|
|
||||
| 2026-05-07 | Erstfassung als Single Source of Truth (verbindlich); Abstimmung mit Stakeholder: Promotion Übung↔Medien, Copyright, Papierkorb 3-stufig, externe Speicher, Embeds getrennt vom Asset-Lifecycle. |
|
||||
| 2026-05-07 | §7.1 Konfiguration Bootstrap vs. Superadmin (`platform_media_storage`), NAS/Mount-Hinweis, Drift-Regel. Umsetzung Start: DB `media_assets`, FK `exercise_media.media_asset_id`, API Speichereinstellungen Superadmin. |
|
||||
|
||||
---
|
||||
|
||||
## 11. Referenzen
|
||||
|
||||
- `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||
- `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend)
|
||||
- `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
- `backend/routers/exercises.py` – Ist-Zustand `exercise_media` bis Refactor
|
||||
|
|
@ -6,6 +6,10 @@
|
|||
**Autor:** Claude Code
|
||||
**Änderungen v1.1:** Rollenbasierte Server-Limits (`EXERCISE_MEDIA_*_MB`)
|
||||
|
||||
> **Zielbild Medien-Archiv, Wiederverwendung, Papierkorb, Copyright, externe Speicherung:**
|
||||
> Verbindliche Single Source of Truth: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`** (gleicher Ordner).
|
||||
> Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor.
|
||||
|
||||
---
|
||||
|
||||
## 1. Upload-Strategie
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
|
||||
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |
|
||||
| admin_users | `GET /api/admin/users` | Plattform | `require_auth` | Admin-Rolle | EXEMPT `check_access_layer_hints.py` |
|
||||
| platform_media_storage | `GET/PUT /api/admin/platform-media-storage` | Plattform | `require_auth` | GET: `is_platform_admin`; PUT: nur `superadmin` | Relativer Pfad unter `MEDIA_ROOT`; keine Secrets; EXEMPT wie admin_users |
|
||||
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT |
|
||||
| catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen |
|
||||
| skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT |
|
||||
|
|
@ -28,7 +29,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C.
|
|||
|
||||
**Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen.
|
||||
|
||||
Letzte Änderung: 2026-05-06 — MULTI_TENANCY §3 Gap-Analyse aktualisiert; Audit um geschützte Profil-Endpunkte ergänzt.
|
||||
Letzte Änderung: 2026-05-07 — `platform_media_storage` Admin-API (Speicherpfad Superadmin).
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ return {"message": "Fehler", "success": False}
|
|||
### 1.4 Mandanten & Zugriffsschicht (Shinkan / ACCESS_LAYER)
|
||||
|
||||
**Verbindlicher Rahmen:** `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md`
|
||||
**Medien-Assets (Archiv, Papierkorb, Promotion, Copyright, externer Speicher):** `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`
|
||||
**Fortlaufendes Inventar:** `.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md`
|
||||
|
||||
**Definition of Done für neue oder geänderte geschützte APIs**, sobald Daten **Verein**, **Sichtbarkeit** oder **mandantenbezogene Listen** betreffen:
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
> | Lessons Learned | `.claude/rules/LESSONS_LEARNED.md` |
|
||||
> | Setup-Dokument | `.claude/docs/working/SHINKAN_PROJECT_SETUP.md` |
|
||||
> | Anforderungen | `.claude/docs/functional/SHINKAN_REQUIREMENTS.md` |
|
||||
> | Medien-Archiv, Lifecycle, Promotion | `.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` |
|
||||
|
||||
## Projekt-Übersicht
|
||||
|
||||
|
|
|
|||
|
|
@ -192,7 +192,7 @@ def read_root():
|
|||
return out
|
||||
|
||||
# Register routers
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
from routers import auth, profiles, exercises, exercise_progression_graphs, clubs, club_memberships, club_join_requests, admin_users, platform_media_storage, skills, training_planning, training_framework_programs, catalogs, maturity_models, matrix_stack_bundle, import_wiki, import_wiki_admin
|
||||
|
||||
app.include_router(auth.router)
|
||||
app.include_router(profiles.router)
|
||||
|
|
@ -202,6 +202,7 @@ app.include_router(clubs.router)
|
|||
app.include_router(club_memberships.router)
|
||||
app.include_router(club_join_requests.router)
|
||||
app.include_router(admin_users.router)
|
||||
app.include_router(platform_media_storage.router)
|
||||
app.include_router(skills.router)
|
||||
app.include_router(training_planning.router)
|
||||
app.include_router(training_framework_programs.router)
|
||||
|
|
|
|||
57
backend/media_storage.py
Normal file
57
backend/media_storage.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
"""Effektives Medien-Wurzelverzeichnis (MEDIA_ROOT + Superadmin-relativer Pfad). Siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §7.1."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
def _default_media_root() -> Path:
|
||||
return Path(os.getenv("MEDIA_ROOT", str(Path(__file__).resolve().parent / "media")))
|
||||
|
||||
|
||||
def normalize_local_relative_root(raw: str) -> str:
|
||||
s = (raw or "").strip().replace("\\", "/")
|
||||
s = s.strip("/")
|
||||
if not s:
|
||||
return ""
|
||||
if ".." in s.split("/"):
|
||||
raise ValueError("Pfad darf nicht '..' enthalten")
|
||||
if s.startswith("/"):
|
||||
raise ValueError("Nur relativer Pfad erlaubt")
|
||||
return s
|
||||
|
||||
|
||||
def get_effective_media_root(cur: Any) -> Path:
|
||||
"""
|
||||
MEDIA_ROOT aus ENV mit optionalem local_relative_root aus platform_media_storage (id=1).
|
||||
"""
|
||||
base = _default_media_root().resolve()
|
||||
rel = ""
|
||||
try:
|
||||
cur.execute(
|
||||
"SELECT local_relative_root FROM platform_media_storage WHERE id = 1",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if row is not None:
|
||||
v = row["local_relative_root"] if isinstance(row, dict) else row[0]
|
||||
rel = normalize_local_relative_root(str(v or ""))
|
||||
except Exception:
|
||||
rel = ""
|
||||
if not rel:
|
||||
return base
|
||||
return (base / rel).resolve()
|
||||
|
||||
|
||||
def path_under_media_root(media_root: Path, storage_key: str) -> Optional[Path]:
|
||||
"""Gibt absoluten Pfad zurück oder None bei Path-Traversal."""
|
||||
key = (storage_key or "").strip().replace("\\", "/").lstrip("/")
|
||||
if not key or ".." in key.split("/"):
|
||||
return None
|
||||
p = (media_root / key).resolve()
|
||||
try:
|
||||
p.relative_to(media_root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
106
backend/migrations/045_media_assets_and_platform_storage.sql
Normal file
106
backend/migrations/045_media_assets_and_platform_storage.sql
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
-- Migration 045: Zentrale media_assets, exercise_media.media_asset_id, platform_media_storage (Superadmin-Pfad).
|
||||
-- Siehe .claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md
|
||||
|
||||
CREATE EXTENSION IF NOT EXISTS pgcrypto;
|
||||
|
||||
CREATE TABLE platform_media_storage (
|
||||
id SMALLINT PRIMARY KEY DEFAULT 1,
|
||||
CONSTRAINT platform_media_storage_singleton CHECK (id = 1),
|
||||
storage_backend VARCHAR(32) NOT NULL DEFAULT 'local',
|
||||
local_relative_root VARCHAR(512) NOT NULL DEFAULT '',
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
INSERT INTO platform_media_storage (id, storage_backend, local_relative_root)
|
||||
VALUES (1, 'local', '')
|
||||
ON CONFLICT (id) DO NOTHING;
|
||||
|
||||
CREATE TABLE media_assets (
|
||||
id SERIAL PRIMARY KEY,
|
||||
mime_type VARCHAR(100),
|
||||
byte_size INT,
|
||||
sha256 CHAR(64) NOT NULL,
|
||||
original_filename VARCHAR(300),
|
||||
visibility VARCHAR(32) NOT NULL DEFAULT 'private',
|
||||
club_id INT REFERENCES clubs(id) ON DELETE SET NULL,
|
||||
uploaded_by_profile_id INT REFERENCES profiles(id) ON DELETE SET NULL,
|
||||
copyright_notice TEXT,
|
||||
storage_backend VARCHAR(32) NOT NULL DEFAULT 'local',
|
||||
storage_key TEXT NOT NULL,
|
||||
lifecycle_state VARCHAR(32) NOT NULL DEFAULT 'active',
|
||||
trash_soft_at TIMESTAMP WITH TIME ZONE,
|
||||
trash_hidden_at TIMESTAMP WITH TIME ZONE,
|
||||
purge_after_at TIMESTAMP WITH TIME ZONE,
|
||||
created_at TIMESTAMP WITH TIME ZONE DEFAULT NOW(),
|
||||
updated_at TIMESTAMP WITH TIME ZONE DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX idx_media_assets_club ON media_assets(club_id);
|
||||
CREATE INDEX idx_media_assets_sha256 ON media_assets(sha256);
|
||||
CREATE INDEX idx_media_assets_lifecycle ON media_assets(lifecycle_state);
|
||||
CREATE UNIQUE INDEX ux_media_assets_storage_key ON media_assets(storage_key);
|
||||
|
||||
ALTER TABLE exercise_media
|
||||
ADD COLUMN IF NOT EXISTS media_asset_id INT REFERENCES media_assets(id) ON DELETE SET NULL;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_exercise_media_asset ON exercise_media(media_asset_id);
|
||||
|
||||
-- Bestand: je distinct storage_key ein Asset; sha256 aus DB-Pfad (Migrations-Platzhalter, nicht Content-Hash)
|
||||
INSERT INTO media_assets (
|
||||
mime_type,
|
||||
byte_size,
|
||||
sha256,
|
||||
original_filename,
|
||||
visibility,
|
||||
club_id,
|
||||
uploaded_by_profile_id,
|
||||
storage_backend,
|
||||
storage_key,
|
||||
copyright_notice,
|
||||
lifecycle_state,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
s.mime_type,
|
||||
s.byte_size,
|
||||
s.sha256,
|
||||
s.original_filename,
|
||||
s.visibility,
|
||||
s.club_id,
|
||||
s.uploaded_by_profile_id,
|
||||
'local',
|
||||
s.storage_key,
|
||||
NULL,
|
||||
'active',
|
||||
s.created_at,
|
||||
NOW()
|
||||
FROM (
|
||||
SELECT DISTINCT ON (storage_key)
|
||||
em.mime_type,
|
||||
em.file_size AS byte_size,
|
||||
encode(digest(trim(em.file_path), 'sha256'), 'hex') AS sha256,
|
||||
em.original_filename,
|
||||
lower(trim(e.visibility)) AS visibility,
|
||||
e.club_id,
|
||||
e.created_by AS uploaded_by_profile_id,
|
||||
regexp_replace(trim(em.file_path), '^/+media/+', '') AS storage_key,
|
||||
em.created_at
|
||||
FROM exercise_media em
|
||||
JOIN exercises e ON e.id = em.exercise_id
|
||||
WHERE em.file_path IS NOT NULL
|
||||
AND trim(em.file_path) <> ''
|
||||
AND em.embed_url IS NULL
|
||||
ORDER BY regexp_replace(trim(em.file_path), '^/+media/+', ''), em.id
|
||||
) s
|
||||
WHERE NOT EXISTS (SELECT 1 FROM media_assets ma WHERE ma.storage_key = s.storage_key);
|
||||
|
||||
UPDATE exercise_media em
|
||||
SET media_asset_id = ma.id
|
||||
FROM media_assets ma
|
||||
WHERE em.media_asset_id IS NULL
|
||||
AND em.file_path IS NOT NULL
|
||||
AND trim(em.file_path) <> ''
|
||||
AND em.embed_url IS NULL
|
||||
AND ma.storage_key = regexp_replace(trim(em.file_path), '^/+media/+', '');
|
||||
|
|
@ -27,6 +27,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
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
|
@ -285,12 +286,6 @@ def _row_created_by(row) -> int:
|
|||
return row[0]
|
||||
|
||||
|
||||
def _ensure_media_dirs():
|
||||
sub = MEDIA_ROOT / "exercises"
|
||||
sub.mkdir(parents=True, exist_ok=True)
|
||||
return sub
|
||||
|
||||
|
||||
def _detect_embed_platform(url: str) -> Optional[str]:
|
||||
if not url:
|
||||
return None
|
||||
|
|
@ -393,19 +388,29 @@ def _count_exercise_media(cur, exercise_id: int) -> int:
|
|||
return int(r["c"] if isinstance(r, dict) else r[0])
|
||||
|
||||
|
||||
def _abs_media_path(file_path_db: str) -> Optional[Path]:
|
||||
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
|
||||
rel = file_path_db.lstrip("/")
|
||||
if rel.startswith("media/"):
|
||||
rel = rel[len("media/") :]
|
||||
p = MEDIA_ROOT / rel
|
||||
p = (media_root / rel).resolve()
|
||||
try:
|
||||
p.resolve().relative_to(MEDIA_ROOT.resolve())
|
||||
p.relative_to(media_root.resolve())
|
||||
except ValueError:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def _resolve_local_media_file(
|
||||
media_root: Path,
|
||||
file_path_db: Optional[str],
|
||||
asset_storage_key: Optional[str],
|
||||
) -> Optional[Path]:
|
||||
if asset_storage_key:
|
||||
return path_under_media_root(media_root, asset_storage_key)
|
||||
return _abs_media_path(file_path_db or "", media_root) if file_path_db else None
|
||||
|
||||
def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
||||
"""
|
||||
Lädt alle M:N Relations für eine Übung und gibt ein vollständiges
|
||||
|
|
@ -515,11 +520,13 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
|
|||
|
||||
# Media (1:N)
|
||||
cur.execute(
|
||||
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context
|
||||
FROM exercise_media
|
||||
WHERE exercise_id = %s
|
||||
ORDER BY sort_order, id""",
|
||||
"""SELECT em.id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
|
||||
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context,
|
||||
em.media_asset_id, ma.copyright_notice AS asset_copyright_notice
|
||||
FROM exercise_media em
|
||||
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
|
||||
WHERE em.exercise_id = %s
|
||||
ORDER BY em.sort_order, em.id""",
|
||||
(exercise_id,)
|
||||
)
|
||||
exercise["media"] = [r2d(r) for r in cur.fetchall()]
|
||||
|
|
@ -1939,9 +1946,12 @@ def _binary_media_response(
|
|||
|
||||
def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]:
|
||||
cur.execute(
|
||||
"""SELECT id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
|
||||
FROM exercise_media WHERE id = %s AND exercise_id = %s""",
|
||||
"""SELECT em.id, em.exercise_id, em.media_type, em.file_path, em.file_size, em.mime_type, em.original_filename,
|
||||
em.embed_url, em.embed_platform, em.title, em.description, em.sort_order, em.is_primary, em.context, em.created_at,
|
||||
em.media_asset_id, ma.storage_key AS asset_storage_key
|
||||
FROM exercise_media em
|
||||
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
|
||||
WHERE em.id = %s AND em.exercise_id = %s""",
|
||||
(media_id, exercise_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
|
@ -1969,7 +1979,12 @@ def download_exercise_media_file(
|
|||
raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL")
|
||||
|
||||
fp = media.get("file_path")
|
||||
abs_p = _abs_media_path(fp) if fp else None
|
||||
media_root = get_effective_media_root(cur)
|
||||
abs_p = _resolve_local_media_file(
|
||||
media_root,
|
||||
fp,
|
||||
media.get("asset_storage_key"),
|
||||
)
|
||||
if not abs_p or not abs_p.is_file():
|
||||
raise HTTPException(status_code=404, detail="Datei nicht gefunden")
|
||||
|
||||
|
|
@ -2065,21 +2080,101 @@ async def upload_exercise_media(
|
|||
ext = ".jpg"
|
||||
elif not ext and mime == "image/png":
|
||||
ext = ".png"
|
||||
digest = hashlib.sha256(raw).hexdigest()[:12]
|
||||
fname = f"{digest}_{exercise_id}{ext}"
|
||||
dest_dir = _ensure_media_dirs()
|
||||
dest_path = dest_dir / fname
|
||||
dest_path.write_bytes(raw)
|
||||
db_path = f"/media/exercises/{fname}"
|
||||
|
||||
cur.execute(
|
||||
"SELECT visibility, club_id, created_by FROM exercises WHERE id = %s",
|
||||
(exercise_id,),
|
||||
)
|
||||
ex_gov = cur.fetchone()
|
||||
if not ex_gov:
|
||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||
ex_vis = (r2d(ex_gov).get("visibility") or "private").strip().lower()
|
||||
ex_club = r2d(ex_gov).get("club_id")
|
||||
media_root = get_effective_media_root(cur)
|
||||
full_sha = hashlib.sha256(raw).hexdigest()
|
||||
|
||||
cur.execute(
|
||||
"""SELECT id, storage_key, byte_size FROM media_assets
|
||||
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||
AND (club_id IS NOT DISTINCT FROM %s)
|
||||
AND lifecycle_state = 'active'
|
||||
LIMIT 1""",
|
||||
(full_sha, ex_vis, ex_club),
|
||||
)
|
||||
existing_asset = cur.fetchone()
|
||||
|
||||
if existing_asset:
|
||||
ea = r2d(existing_asset)
|
||||
aid = ea["id"]
|
||||
sk = ea["storage_key"]
|
||||
sz = ea.get("byte_size") or len(raw)
|
||||
db_path = f"/media/{sk}"
|
||||
cur.execute(
|
||||
f"""INSERT INTO exercise_media (
|
||||
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, context, is_primary, sort_order
|
||||
embed_url, embed_platform, title, description, context, is_primary, sort_order,
|
||||
media_asset_id
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}
|
||||
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s
|
||||
)
|
||||
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at""",
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at,
|
||||
media_asset_id""",
|
||||
(
|
||||
exercise_id,
|
||||
media_type,
|
||||
db_path,
|
||||
sz,
|
||||
mime,
|
||||
file.filename,
|
||||
title or None,
|
||||
description or None,
|
||||
context,
|
||||
is_primary,
|
||||
exercise_id,
|
||||
aid,
|
||||
),
|
||||
)
|
||||
else:
|
||||
storage_key = f"exercises/{full_sha}{ext}"
|
||||
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,
|
||||
file.filename,
|
||||
ex_vis,
|
||||
ex_club,
|
||||
profile_id,
|
||||
storage_key,
|
||||
),
|
||||
)
|
||||
ar = cur.fetchone()
|
||||
aid = r2d(ar)["id"]
|
||||
db_path = f"/media/{storage_key}"
|
||||
cur.execute(
|
||||
f"""INSERT INTO exercise_media (
|
||||
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, context, is_primary, sort_order,
|
||||
media_asset_id
|
||||
) VALUES (
|
||||
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s
|
||||
)
|
||||
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at,
|
||||
media_asset_id""",
|
||||
(
|
||||
exercise_id,
|
||||
media_type,
|
||||
|
|
@ -2092,6 +2187,7 @@ async def upload_exercise_media(
|
|||
context,
|
||||
is_primary,
|
||||
exercise_id,
|
||||
aid,
|
||||
),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
|
|
@ -2165,7 +2261,8 @@ def update_exercise_media(
|
|||
conn.commit()
|
||||
cur.execute(
|
||||
"""SELECT id, media_type, file_path, file_size, mime_type, original_filename,
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at
|
||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at,
|
||||
media_asset_id
|
||||
FROM exercise_media WHERE id = %s""",
|
||||
(media_id,),
|
||||
)
|
||||
|
|
@ -2179,27 +2276,56 @@ def delete_exercise_media(
|
|||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
profile_id = tenant.profile_id
|
||||
unlink_path: Optional[Path] = None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
||||
media_root = get_effective_media_root(cur)
|
||||
cur.execute(
|
||||
"""SELECT file_path FROM exercise_media WHERE id = %s AND exercise_id = %s""",
|
||||
"""SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key
|
||||
FROM exercise_media em
|
||||
LEFT JOIN media_assets ma ON ma.id = em.media_asset_id
|
||||
WHERE em.id = %s AND em.exercise_id = %s""",
|
||||
(media_id, exercise_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
fp = row["file_path"] if isinstance(row, dict) else row[0]
|
||||
rec = r2d(row)
|
||||
fp = rec.get("file_path")
|
||||
media_asset_id = rec.get("media_asset_id")
|
||||
asset_storage_key = rec.get("asset_storage_key")
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
|
||||
(media_id, exercise_id),
|
||||
)
|
||||
|
||||
if media_asset_id:
|
||||
cur.execute(
|
||||
"SELECT COUNT(*) AS c FROM exercise_media WHERE media_asset_id = %s",
|
||||
(media_asset_id,),
|
||||
)
|
||||
cnt = int(r2d(cur.fetchone())["c"])
|
||||
if cnt == 0:
|
||||
cur.execute(
|
||||
"DELETE FROM media_assets WHERE id = %s RETURNING storage_key",
|
||||
(media_asset_id,),
|
||||
)
|
||||
del_asset = cur.fetchone()
|
||||
sk = None
|
||||
if del_asset:
|
||||
sk = r2d(del_asset).get("storage_key") or asset_storage_key
|
||||
if sk:
|
||||
unlink_path = path_under_media_root(media_root, sk)
|
||||
elif fp:
|
||||
unlink_path = _abs_media_path(fp, media_root)
|
||||
|
||||
conn.commit()
|
||||
|
||||
abs_p = _abs_media_path(fp) if fp else None
|
||||
if abs_p and abs_p.is_file():
|
||||
if unlink_path and unlink_path.is_file():
|
||||
try:
|
||||
abs_p.unlink()
|
||||
unlink_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
|
||||
|
||||
|
|
|
|||
97
backend/routers/platform_media_storage.py
Normal file
97
backend/routers/platform_media_storage.py
Normal file
|
|
@ -0,0 +1,97 @@
|
|||
"""Superadmin: Speicherpfad-Konfiguration (§7.1 MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)."""
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from club_tenancy import is_platform_admin
|
||||
from db import get_db, get_cursor, r2d
|
||||
from auth import require_auth
|
||||
from media_storage import _default_media_root, get_effective_media_root, normalize_local_relative_root
|
||||
|
||||
router = APIRouter(prefix="/api/admin", tags=["admin", "media-storage"])
|
||||
|
||||
|
||||
class PlatformMediaStorageUpdate(BaseModel):
|
||||
local_relative_root: str = Field(
|
||||
"",
|
||||
description="Relativer Unterordner unter MEDIA_ROOT (z. B. nas/videos). Leer = nur MEDIA_ROOT.",
|
||||
max_length=512,
|
||||
)
|
||||
|
||||
|
||||
class PlatformMediaStorageOut(BaseModel):
|
||||
storage_backend: str
|
||||
local_relative_root: str
|
||||
media_root_env: str
|
||||
effective_media_root: str
|
||||
|
||||
|
||||
def _require_superadmin(session: dict) -> None:
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if role != "superadmin":
|
||||
raise HTTPException(status_code=403, detail="Nur Superadmin")
|
||||
|
||||
|
||||
@router.get("/platform-media-storage", response_model=PlatformMediaStorageOut)
|
||||
def get_platform_media_storage(session: dict = Depends(require_auth)):
|
||||
"""Lesen: Plattform-Admin (admin/superadmin) – Hilfe für Betrieb."""
|
||||
role = (session.get("role") or "").strip().lower()
|
||||
if not is_platform_admin(role):
|
||||
raise HTTPException(status_code=403, detail="Keine Berechtigung")
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""SELECT storage_backend, local_relative_root
|
||||
FROM platform_media_storage WHERE id = 1""",
|
||||
)
|
||||
row = cur.fetchone()
|
||||
if not row:
|
||||
backend, rel = "local", ""
|
||||
else:
|
||||
d = r2d(row)
|
||||
backend = d.get("storage_backend") or "local"
|
||||
rel = str(d.get("local_relative_root") or "")
|
||||
eff = get_effective_media_root(cur)
|
||||
return PlatformMediaStorageOut(
|
||||
storage_backend=backend,
|
||||
local_relative_root=rel,
|
||||
media_root_env=str(_default_media_root().resolve()),
|
||||
effective_media_root=str(eff),
|
||||
)
|
||||
|
||||
|
||||
@router.put("/platform-media-storage", response_model=PlatformMediaStorageOut)
|
||||
def put_platform_media_storage(
|
||||
body: PlatformMediaStorageUpdate,
|
||||
session: dict = Depends(require_auth),
|
||||
):
|
||||
"""Schreiben: nur superadmin."""
|
||||
_require_superadmin(session)
|
||||
profile_id = session["profile_id"]
|
||||
try:
|
||||
rel_norm = normalize_local_relative_root(body.local_relative_root)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e)) from e
|
||||
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
cur.execute(
|
||||
"""UPDATE platform_media_storage
|
||||
SET local_relative_root = %s, updated_at = NOW(), updated_by_profile_id = %s
|
||||
WHERE id = 1
|
||||
RETURNING storage_backend, local_relative_root""",
|
||||
(rel_norm, profile_id),
|
||||
)
|
||||
row = cur.fetchone()
|
||||
conn.commit()
|
||||
if not row:
|
||||
raise HTTPException(status_code=500, detail="platform_media_storage fehlt")
|
||||
d = r2d(row)
|
||||
eff = get_effective_media_root(cur)
|
||||
return PlatformMediaStorageOut(
|
||||
storage_backend=str(d.get("storage_backend") or "local"),
|
||||
local_relative_root=str(d.get("local_relative_root") or ""),
|
||||
media_root_env=str(_default_media_root().resolve()),
|
||||
effective_media_root=str(eff),
|
||||
)
|
||||
|
|
@ -21,6 +21,7 @@ EXEMPT_ROUTERS: frozenset[str] = frozenset(
|
|||
{
|
||||
"auth.py",
|
||||
"admin_users.py",
|
||||
"platform_media_storage.py",
|
||||
"catalogs.py",
|
||||
"skills.py",
|
||||
"maturity_models.py",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.40"
|
||||
BUILD_DATE = "2026-05-06"
|
||||
DB_SCHEMA_VERSION = "20260506043"
|
||||
APP_VERSION = "0.8.41"
|
||||
BUILD_DATE = "2026-05-07"
|
||||
DB_SCHEMA_VERSION = "20260507045"
|
||||
|
||||
MODULE_VERSIONS = {
|
||||
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
|
||||
|
|
@ -12,10 +12,11 @@ MODULE_VERSIONS = {
|
|||
"club_memberships": "1.0.1", # Depends(get_tenant_context)
|
||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||
"admin_users": "1.0.0", # GET /api/admin/users
|
||||
"platform_media_storage": "1.0.0", # GET/PUT /api/admin/platform-media-storage (Superadmin-Pfad unter MEDIA_ROOT)
|
||||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.10.0", # GET /exercises: focus_area_must_include/exclude_ids, focus_only_without_focus_areas; UI +/- Fokusregeln
|
||||
"exercises": "2.11.0", # media_assets + Dedupe-Upload; effektives MEDIA_ROOT aus platform_media_storage
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -27,6 +28,16 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.41",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"DB 045: media_assets, exercise_media.media_asset_id, platform_media_storage; Migration bestehender Medien; Upload-Dedupe pro sha256+visibility+club_id",
|
||||
"Effektives Medien-Verzeichnis: MEDIA_ROOT + Superadmin local_relative_root (GET/PUT /api/admin/platform-media-storage)",
|
||||
"Neue Uploads: storage_key exercises/{sha256}{ext}; Download/Delete nutzen media_assets",
|
||||
"api.js: getPlatformMediaStorage, putPlatformMediaStorage",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.40",
|
||||
"date": "2026-05-06",
|
||||
|
|
|
|||
|
|
@ -120,6 +120,18 @@ export async function listAdminUsers() {
|
|||
return request('/api/admin/users')
|
||||
}
|
||||
|
||||
/** Medien-Speicher (MEDIA_ROOT + relativer Unterordner) — GET: admin/superadmin, PUT: nur superadmin. */
|
||||
export async function getPlatformMediaStorage() {
|
||||
return request('/api/admin/platform-media-storage')
|
||||
}
|
||||
|
||||
export async function putPlatformMediaStorage(payload) {
|
||||
return request('/api/admin/platform-media-storage', {
|
||||
method: 'PUT',
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
}
|
||||
|
||||
export async function updateProfile(profileId, data) {
|
||||
return request(`/api/profiles/${profileId}`, {
|
||||
method: 'PUT',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user