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

- 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:
Lars 2026-05-07 12:36:46 +02:00
parent 161d520329
commit 7284c577d7
15 changed files with 678 additions and 60 deletions

View File

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

View File

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

View 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

View File

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

View File

@ -17,6 +17,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| 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 AC.
**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).
---

View File

@ -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:

View File

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

View File

@ -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
View 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

View 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/+', '');

View File

@ -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)

View 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),
)

View File

@ -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",

View File

@ -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",

View File

@ -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',