diff --git a/.claude/docs/PROJECT_STATUS.md b/.claude/docs/PROJECT_STATUS.md index 25283f0..d84991a 100644 --- a/.claude/docs/PROJECT_STATUS.md +++ b/.claude/docs/PROJECT_STATUS.md @@ -20,7 +20,19 @@ **Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md) -**Nächste Schritte (Auszug):** +**Nächste Schritte — Medien & Archiv** (siehe `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`): + +1. Papierkorb-Lebenszyklus (§5) + Hintergrundjob +2. Übungs-Promotions-Dialog inkl. Medien-Freigabe + Copyright-Pflicht bei `official` +3. Medienmanager-/Archiv-UI (Superadmin + Verein); ggf. „Aus Archiv verknüpfen“ +4. S3-/ externes Backend hinter Speicher-Abstraktion +5. **Inline-Medien im Fließtext** erst nach stabilem Archiv (Spec §11 — Platzhalter, zentraler Renderer) + +**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11** festgehalten; Umsetzung bewusst verschoben, um Refactor zu vermeiden. + +--- + +**Nächste Schritte (Auszug — Planung/Rahmen):** 1. Kalender‑UI: „Aus Rahmen übernehmen“ an **`from-framework-slot`** anbinden; ggf. Bulk. 2. Governance: Sichtbarkeit **club/official** für Rahmen so ausprägen, dass andere Trainer kopieren dürfen (Policy + API). @@ -95,6 +107,8 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu ### 🔲 In Arbeit / Backlog +- [ ] **Medien:** Papierkorb (§5), Promotion/Copyright, Archiv-UI, S3 — Roadmap oben im Executive Summary +- [ ] **Medien:** Inline im Fließtext — erst nach Archiv-Stabilität (**`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**) - [ ] Admin-UI für Skill-Kategorien (CRUD) – falls noch offen - [ ] Responsive Design / Dark Mode / PWA - [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus @@ -146,7 +160,7 @@ 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) | -| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ Single Source of Truth | +| 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 | ✅ Diese Datei | --- diff --git a/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md b/.claude/docs/technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md index 34bc3c0..0f13134 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 +**Stand:** 2026-05-07 (§11 Inline-Plan ergänzt) **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. @@ -159,7 +159,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa **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**. +**Drift:** Jede Änderung an Speicher-Konfiguration oder Asset-Schema → Migration + Eintrag **§10 Changelog** + bei neuen Endpoints **ACCESS_LAYER_ENDPOINT_AUDIT.md**. Änderungen an **Inline-Platzhaltern** (§11) → ebenfalls Changelog + ggf. Frontend-Sanitize-Regeln dokumentieren. --- @@ -177,6 +177,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa | 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 | +| **Inline-Referenzen** in Übungstexten (§11) | Renderer/Sanitizer-Regeln + dieses Dokument §11; kein zweites Rechtemodell | | Implementierungs-Meilenstein | `backend/version.py` (`MODULE_VERSIONS` / Schema-Kommentar) und bei größerem Release `PROJECT_STATUS.md` | --- @@ -186,11 +187,50 @@ 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 | §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 +## 11. Inline-Medien im Fließtext (Planung, Leitplanken) + +**Status:** nicht implementiert; verbindlich nur als **Richtschnur**, damit später **kein Big-Bang-Refactor** nötig ist. + +### 11.1 Ziel + +- Medien (Player, Bild) sollen **an definierter Stelle** in Feldern wie Ablauf / Ziel / Notizen erscheinen können – zusätzlich oder statt reiner Zuordnung zu den Sektionen `ablauf` / `detail` / `trainer_hint`. +- **Keine zweite Sichtbarkeit:** Inline verweist immer auf dieselbe **Übungs-Medium-Zeile** (`exercise_media.id`) bzw. indirekt auf das gleiche Asset wie die Medienliste; **Lesen/Ausliefern** nur nach **bestehender** Übungs- + Medien-Governance (§4.1). + +### 11.2 Platzhalter-Konvention (Vorschlag für spätere Umsetzung) + +- Beim **Speichern** im Rich-Text: markierter Verweis, z. B. + `data-shinkan-exercise-media=""` auf einem **neutralen** Element (`span`/`figure`), **oder** eine interne Kurzsyntax (`{{exerciseMedia:123}}`), die der Server beim Speichern in eine **kanonische** HTML-Form überführt. +- **Final festlegen** beim Start der Implementierung (ein Format, nicht mehrere parallele). + +### 11.3 Rendering & Sicherheit + +- **Ein zentraler Pfad** „Übungstext für Anzeige aufbereiten“: HTML sanitizen (Allowlist), erlaubte Platzhalter auflösen, **ID gehört zur aktuellen Übung** und Medium ist für den Nutzer **sichtbar** – sonst Platzhalter mit neutralem Hinweis oder ausblenden. +- **XSS/CSP:** keine rohen `iframe`/Skripte aus Nutzer-HTML ohne Kontrolle; eingebettete Player nur über kontrollierte Komponenten. + +### 11.4 Koexistenz mit Sektions-Medien + +- **Liste + Inline** dürfen dasselbe `exercise_media` referenzieren (ein Player an zwei Stellen) – Produktentscheidung: später optional „Duplikat vermeiden“-Hinweis in der UI. +- Import/Wiki: vor großen Content-Migrationen **Syntax festlegen**, damit nicht irreversibel „falsches“ HTML importiert wird. + +### 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. + +### 11.6 Refactor-Vermeidung (jetzt schon) + +- Neue Features **nicht** so bauen, dass HTML aus Übungstexten an **vielen** Stellen „roh“ gerendert wird – **ein** wiederverwendbarer Renderer vorbereiten (schrittweise einziehen). +- Medien immer über **stabile IDs** anbinden, nicht nur über Datei-URLs im Text. + +--- + +## 12. Referenzen - `.claude/docs/technical/ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` - `.claude/docs/technical/MEDIA_UPLOAD_SPEC.md` (Limits, MIME, Embed-Typen im aktuellen Backend) diff --git a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md index 4b2f503..3707e94 100644 --- a/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md +++ b/.claude/docs/technical/MEDIA_UPLOAD_SPEC.md @@ -6,8 +6,8 @@ **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). +> **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). > Dieses Dokument bleibt maßgeblich für **konkrete Upload-Limits, MIME-Liste und Embed-Hosting** des aktuellen Stands bis zum Refactor. --- diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 785d4e6..ee1e3c4 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -18,6 +18,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | 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 | +| media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | Papierkorb: `trash_soft` / `trash_hidden` / `recover` / `purge`; Rechte über Uploader, `can_manage_club_org`, Superadmin (`assert_can_manage_media_asset_lifecycle`) | | 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 | @@ -29,7 +30,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-07 — `platform_media_storage` Admin-API (Speicherpfad Superadmin). +Letzte Änderung: 2026-05-07 — `media_assets` Lifecycle-API (Papierkorb); `platform_media_storage` Admin-API (Speicherpfad Superadmin). --- diff --git a/CLAUDE.md b/CLAUDE.md index a4a95dd..a69a599 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -106,7 +106,7 @@ Kurz (Stand 2026-05-05): App **0.8.10**, DB‑Schema‑Version **`20260505037`** - `exercises` - Übungen (Kernobjekt) - `exercise_variants` - Übungsvarianten - `exercise_skills` - M:N Übung ↔ Fähigkeit -- `exercise_media` - Medien (Bilder, Videos) +- `exercise_media` - Medien (Bilder, Videos); Zielbild Archiv & Lifecycle: **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`**. **Inline im Fließtext** (Übungstexte): geplant §11 derselben Spec — Anker `exercise_media.id`, einheitlicher Render-Pfad; noch nicht implementiert. **Trainingsplanung / Rahmen:** - `training_plan_templates` + Sektionsvorlagen — Mikrovorlage pro Einheit (Migration **031**) diff --git a/backend/main.py b/backend/main.py index ac92a9d..d3a6bfc 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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, platform_media_storage, 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, media_assets, 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) @@ -203,6 +203,7 @@ 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(media_assets.router) app.include_router(skills.router) app.include_router(training_planning.router) app.include_router(training_framework_programs.router) diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py new file mode 100644 index 0000000..00ca6d9 --- /dev/null +++ b/backend/media_lifecycle.py @@ -0,0 +1,177 @@ +""" +Medien-Lebenszyklus (Papierkorb) — siehe MEDIA_ASSETS_AND_ARCHIVE_SPEC.md §5. +""" +from __future__ import annotations + +import logging +import os +from datetime import datetime, timedelta, timezone +from typing import Any, Optional + +from fastapi import HTTPException + +from club_tenancy import can_manage_club_org, is_platform_admin +from db import r2d +from media_storage import get_effective_media_root, path_under_media_root + +logger = logging.getLogger(__name__) + +LC_ACTIVE = "active" +LC_TRASH_SOFT = "trash_soft" +LC_TRASH_HIDDEN = "trash_hidden" + +SOFT_TO_HIDDEN_DAYS = max(1, int(os.getenv("MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS", "30"))) +HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS", "90"))) + + +def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None: + """ + Wer Medien in Papierkorb / Recovery / Purge versetzen darf (§5.2 Kurzfassung). + """ + profile_id = tenant.profile_id + role = (tenant.global_role or "").strip().lower() + 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: + raise HTTPException(status_code=403, detail="Ungültiges Vereins-Medium") + if can_manage_club_org(cur, profile_id, int(cid), role): + return + raise HTTPException(status_code=403, detail="Nur Vereinsorganisation/Plattform-Admin") + raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") + + +def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]: + cur.execute( + """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, + storage_key, storage_backend, trash_soft_at, trash_hidden_at, purge_after_at + FROM media_assets WHERE id = %s""", + (asset_id,), + ) + row = cur.fetchone() + return r2d(row) if row else None + + +def purge_asset_filesystem(cur: Any, asset: dict) -> None: + sk = asset.get("storage_key") + if not sk: + return + root = get_effective_media_root(cur) + p = path_under_media_root(root, str(sk)) + if p and p.is_file(): + try: + p.unlink() + except OSError as e: + logger.warning("Physische Medien-Löschung fehlgeschlagen: %s", e) + + +def purge_media_asset(cur: Any, conn: Any, asset_id: int) -> bool: + """Löscht Verknüpfungen, Datei und DB-Zeile. Returns True wenn ausgeführt.""" + cur.execute( + """SELECT id, storage_key FROM media_assets + WHERE id = %s AND lifecycle_state = %s""", + (asset_id, LC_TRASH_HIDDEN), + ) + row = cur.fetchone() + if not row: + return False + asset = r2d(row) + cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,)) + purge_asset_filesystem(cur, asset) + cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,)) + conn.commit() + return True + + +def transition_to_trash_soft(cur: Any, conn: Any, asset_id: int) -> dict: + cur.execute( + """UPDATE media_assets + SET lifecycle_state = %s, trash_soft_at = NOW(), updated_at = NOW(), + trash_hidden_at = NULL, purge_after_at = NULL + WHERE id = %s AND lifecycle_state = %s + RETURNING id, lifecycle_state, trash_soft_at""", + (LC_TRASH_SOFT, asset_id, LC_ACTIVE), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Nur aktive Medien können in den Papierkorb") + conn.commit() + return r2d(row) + + +def transition_to_trash_hidden(cur: Any, conn: Any, asset_id: int, *, set_purge_after: Optional[datetime] = None) -> dict: + now = datetime.now(timezone.utc) + if set_purge_after is None: + set_purge_after = now + timedelta(days=HIDDEN_TO_PURGE_DAYS) + cur.execute( + """UPDATE media_assets + SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(), + purge_after_at = %s + WHERE id = %s AND lifecycle_state IN (%s, %s) + RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""", + (LC_TRASH_HIDDEN, set_purge_after, asset_id, LC_ACTIVE, LC_TRASH_SOFT), + ) + row = cur.fetchone() + if not row: + raise HTTPException( + status_code=400, + detail="Nur aktive oder Papierkorb-Stufe-1 Medien können ausgeblendet werden", + ) + conn.commit() + return r2d(row) + + +def transition_recover_from_hidden(cur: Any, conn: Any, asset_id: int) -> dict: + cur.execute( + """UPDATE media_assets + SET lifecycle_state = %s, updated_at = NOW(), + trash_hidden_at = NULL, purge_after_at = NULL + WHERE id = %s AND lifecycle_state = %s + RETURNING id, lifecycle_state, trash_soft_at""", + (LC_TRASH_SOFT, asset_id, LC_TRASH_HIDDEN), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=400, detail="Nur ausgeblendete Medien können zurückgestuft werden") + conn.commit() + return r2d(row) + + +def run_retention_pass(cur: Any, conn: Any) -> dict: + """ + Automatik: trash_soft älter als SOFT_TO_HIDDEN_DAYS → trash_hidden; + trash_hidden mit purge_after_at in der Vergangenheit → purge. + """ + cutoff_soft = datetime.now(timezone.utc) - timedelta(days=SOFT_TO_HIDDEN_DAYS) + cur.execute( + """UPDATE media_assets + SET lifecycle_state = %s, trash_hidden_at = NOW(), updated_at = NOW(), + purge_after_at = NOW() + (%s * INTERVAL '1 day') + WHERE lifecycle_state = %s AND trash_soft_at IS NOT NULL AND trash_soft_at <= %s + RETURNING id""", + (LC_TRASH_HIDDEN, HIDDEN_TO_PURGE_DAYS, LC_TRASH_SOFT, cutoff_soft), + ) + n_hidden = len(cur.fetchall()) + conn.commit() + + cur.execute( + """SELECT id FROM media_assets + WHERE lifecycle_state = %s AND purge_after_at IS NOT NULL AND purge_after_at <= NOW()""", + (LC_TRASH_HIDDEN,), + ) + purge_ids = [r2d(r)["id"] for r in cur.fetchall()] + purged = 0 + for aid in purge_ids: + if purge_media_asset(cur, conn, int(aid)): + purged += 1 + + return {"moved_to_hidden": n_hidden, "purged": purged} diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index b2720e9..4d1ea01 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -541,7 +541,8 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict: cur.execute( """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 + em.media_asset_id, ma.copyright_notice AS asset_copyright_notice, + ma.lifecycle_state AS asset_lifecycle_state FROM exercise_media em LEFT JOIN media_assets ma ON ma.id = em.media_asset_id WHERE em.exercise_id = %s @@ -1967,7 +1968,8 @@ def _fetch_media_row(cur, exercise_id: int, media_id: int) -> Optional[dict]: cur.execute( """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 + em.media_asset_id, ma.storage_key AS asset_storage_key, + ma.lifecycle_state AS asset_lifecycle_state 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""", @@ -1997,6 +1999,12 @@ def download_exercise_media_file( if (media.get("embed_url") or "").strip(): raise HTTPException(status_code=400, detail="Embed-Medien haben keine Datei-URL") + lc = (media.get("asset_lifecycle_state") or "active").strip().lower() + if lc == "trash_hidden": + _assert_can_edit_exercise(cur, exercise_id, tenant) + elif lc not in ("active", "trash_soft", ""): + raise HTTPException(status_code=404, detail="Medium nicht verfügbar") + fp = media.get("file_path") media_root = get_effective_media_root(cur) abs_p = _resolve_local_media_file( @@ -2116,9 +2124,9 @@ async def upload_exercise_media( """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' + AND lifecycle_state = %s LIMIT 1""", - (full_sha, ex_vis, ex_club), + (full_sha, ex_vis, ex_club, "active"), ) existing_asset = cur.fetchone() diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py new file mode 100644 index 0000000..bde4a95 --- /dev/null +++ b/backend/routers/media_assets.py @@ -0,0 +1,60 @@ +"""Lifecycle für media_assets (Papierkorb) — MEDIA_ASSETS_AND_ARCHIVE_SPEC §5.""" +from __future__ import annotations + +from typing import Literal + +from fastapi import APIRouter, Depends, HTTPException +from pydantic import BaseModel + +from db import get_db, get_cursor +from tenant_context import TenantContext, get_tenant_context +from media_lifecycle import ( + assert_can_manage_media_asset_lifecycle, + fetch_media_asset_row, + purge_media_asset, + transition_recover_from_hidden, + transition_to_trash_hidden, + transition_to_trash_soft, +) + +router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) + + +class MediaLifecycleBody(BaseModel): + action: Literal["trash_soft", "trash_hidden", "recover", "purge"] + + +@router.post("/{asset_id}/lifecycle") +def post_media_asset_lifecycle( + asset_id: int, + body: MediaLifecycleBody, + tenant: TenantContext = Depends(get_tenant_context), +): + """Papierkorb-Übergänge (manuell). Rechte gemäß Spec §5.2.""" + with get_db() as conn: + cur = get_cursor(conn) + asset = fetch_media_asset_row(cur, asset_id) + if not asset: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + assert_can_manage_media_asset_lifecycle(cur, tenant, asset) + + action = body.action + if action == "trash_soft": + return transition_to_trash_soft(cur, conn, asset_id) + if action == "trash_hidden": + return transition_to_trash_hidden(cur, conn, asset_id) + if action == "recover": + return transition_recover_from_hidden(cur, conn, asset_id) + if action == "purge": + state = (asset.get("lifecycle_state") or "").strip().lower() + if state != "trash_hidden": + raise HTTPException( + status_code=400, + detail="Nur ausgeblendete Medien (Stufe 2) dürfen endgültig gelöscht werden", + ) + ok = purge_media_asset(cur, conn, asset_id) + if not ok: + raise HTTPException(status_code=400, detail="Löschen nicht möglich") + return {"ok": True, "purged": asset_id} + + raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") diff --git a/backend/scripts/media_retention_job.py b/backend/scripts/media_retention_job.py new file mode 100644 index 0000000..2ef70cc --- /dev/null +++ b/backend/scripts/media_retention_job.py @@ -0,0 +1,35 @@ +#!/usr/bin/env python3 +""" +Automatische Medien-Retention (Papierkorb Stufe 1→2, Purge wenn fällig). + +Lauf z. B. täglich per Cron: + cd /path/to/backend && python scripts/media_retention_job.py + +Umgebung wie Backend (DB_*), optional: + MEDIA_TRASH_SOFT_TO_HIDDEN_DAYS (Default 30) + MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS (Default 90) +""" +from __future__ import annotations + +import os +import sys + +# Repo-Root: backend/scripts -> parents[1] == backend +_ROOT = os.path.abspath(os.path.join(os.path.dirname(__file__), "..")) +if _ROOT not in sys.path: + sys.path.insert(0, _ROOT) + +from db import get_db, get_cursor # noqa: E402 +from media_lifecycle import run_retention_pass # noqa: E402 + + +def main() -> int: + with get_db() as conn: + cur = get_cursor(conn) + summary = run_retention_pass(cur, conn) + print(summary) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/backend/tests/test_platform_media_storage.py b/backend/tests/test_platform_media_storage.py new file mode 100644 index 0000000..bdb1240 --- /dev/null +++ b/backend/tests/test_platform_media_storage.py @@ -0,0 +1,74 @@ +"""GET/PUT /api/admin/platform-media-storage — Auth-Matrix (kein Live-DB nötig).""" +from __future__ import annotations + +import os +from unittest.mock import MagicMock, patch + +import pytest +from fastapi.testclient import TestClient + +os.environ.setdefault("SKIP_DB_MIGRATE", "1") + +from auth import require_auth +from main import app + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture(autouse=True) +def _clear_overrides(): + yield + app.dependency_overrides.pop(require_auth, None) + + +def test_platform_media_storage_get_requires_platform_admin(client: TestClient) -> None: + def _trainer(): + return {"profile_id": 1, "role": "trainer"} + + app.dependency_overrides[require_auth] = _trainer + r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"}) + assert r.status_code == 403 + + +def test_platform_media_storage_put_requires_superadmin(client: TestClient) -> None: + def _admin(): + return {"profile_id": 1, "role": "admin"} + + app.dependency_overrides[require_auth] = _admin + r = client.put( + "/api/admin/platform-media-storage", + headers={"X-Auth-Token": "t", "Content-Type": "application/json"}, + json={"local_relative_root": "foo"}, + ) + assert r.status_code == 403 + + +@patch("routers.platform_media_storage.get_effective_media_root", return_value=__import__("pathlib").Path("/tmp/media")) +def test_platform_media_storage_get_ok_for_admin(mock_root, client: TestClient) -> None: + def _admin(): + return {"profile_id": 1, "role": "admin"} + + app.dependency_overrides[require_auth] = _admin + + mock_cm = MagicMock() + mock_conn = MagicMock() + mock_cm.__enter__.return_value = mock_conn + mock_cm.__exit__.return_value = False + mock_cur = MagicMock() + mock_cur.fetchone.return_value = { + "storage_backend": "local", + "local_relative_root": "nas", + } + + with patch("routers.platform_media_storage.get_db", return_value=mock_cm), patch( + "routers.platform_media_storage.get_cursor", return_value=mock_cur + ): + r = client.get("/api/admin/platform-media-storage", headers={"X-Auth-Token": "t"}) + + assert r.status_code == 200 + body = r.json() + assert body["storage_backend"] == "local" + assert body["local_relative_root"] == "nas" diff --git a/backend/version.py b/backend/version.py index c556b2b..e78b410 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.41" +APP_VERSION = "0.8.42" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260507045" @@ -13,10 +13,11 @@ MODULE_VERSIONS = { "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.0.0", # POST /api/media-assets/{id}/lifecycle (Papierkorb §5) "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.11.0", # media_assets + Dedupe-Upload; effektives MEDIA_ROOT aus platform_media_storage + "exercises": "2.12.0", # Detail/Liste: asset_lifecycle_state; Download trash_hidden nur für Bearbeiter; Lesen filtert hidden "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -28,6 +29,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.42", + "date": "2026-05-07", + "changes": [ + "Medien-Papierkorb: POST /api/media-assets/{id}/lifecycle (trash_soft, trash_hidden, recover, purge); Retention-Job scripts/media_retention_job.py", + "Übungen: GET-Detail inkl. asset_lifecycle_state; Bearbeitungsrechte Erweiterung (Vereinsplanung); Frontend Übung bearbeiten: Reihenfolge, Papierkorb-Actions; Detail/Katalog: trash_hidden ausgeblendet, Hinweis trash_soft", + "Fix: ExerciseDetailPage zeigt „Hinweise für Trainer“ wieder an", + ], + }, { "version": "0.8.41", "date": "2026-05-07", diff --git a/frontend/src/components/ExerciseFullContent.jsx b/frontend/src/components/ExerciseFullContent.jsx index 881c8c3..cd94b9a 100644 --- a/frontend/src/components/ExerciseFullContent.jsx +++ b/frontend/src/components/ExerciseFullContent.jsx @@ -113,6 +113,10 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise if (!exercise) return null const meta = metaParts(exercise) + const visibleMedia = (exercise.media || []).filter((m) => { + const lc = String(m.asset_lifecycle_state || 'active').toLowerCase() + return lc !== 'trash_hidden' + }) return (
@@ -161,14 +165,19 @@ export default function ExerciseFullContent({ exercise, loading, error, exercise )} - {(exercise.media || []).length > 0 && ( + {visibleMedia.length > 0 && (

Medien

- {exercise.media.map((m) => ( + {visibleMedia.map((m) => (
{m.title || m.original_filename || m.media_type} + {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && ( +

+ Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung. +

+ )} {m.description &&

{m.description}

}
diff --git a/frontend/src/pages/ExerciseDetailPage.jsx b/frontend/src/pages/ExerciseDetailPage.jsx index 1827c9f..ebee075 100644 --- a/frontend/src/pages/ExerciseDetailPage.jsx +++ b/frontend/src/pages/ExerciseDetailPage.jsx @@ -151,6 +151,10 @@ function ExerciseDetailPage() { if (!exercise) return null const meta = metaParts(exercise) + const visibleMedia = (exercise.media || []).filter((m) => { + const lc = String(m.asset_lifecycle_state || 'active').toLowerCase() + return lc !== 'trash_hidden' + }) return (
@@ -211,12 +215,17 @@ function ExerciseDetailPage() {
)} - {(exercise.media || []).length > 0 && ( + {visibleMedia.length > 0 && (

Medien

- {exercise.media.map((m) => ( + {visibleMedia.map((m) => (
{m.title || m.original_filename || m.media_type} + {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && ( +

+ Hinweis: Dieses Medium ist im Papierkorb und steht künftig nicht mehr zur Verfügung. +

+ )} {m.description &&

{m.description}

}
diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 6770898..3144dd9 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -612,6 +612,16 @@ function ExerciseFormPage() { } } + const runMediaLifecycle = async (assetId, action) => { + if (!assetId) return + try { + await api.postMediaAssetLifecycle(assetId, action) + await refreshMedia() + } catch (e) { + alert(e.message || String(e)) + } + } + const moveMediaRow = async (idx, dir) => { if (!exerciseId) return const j = idx + dir @@ -1321,6 +1331,82 @@ function ExerciseFormPage() { )}
+ {m.media_asset_id ? ( +
+ {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && ( +

+ Papierkorb (Stufe 1): für neue Zuordnungen gesperrt. +

+ )} + {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && ( +

+ Ausgeblendet (Stufe 2): in der Übungsansicht nicht sichtbar. +

+ )} +
+ {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'active' && ( + + )} + {['active', 'trash_soft'].includes(String(m.asset_lifecycle_state || 'active').toLowerCase()) && ( + + )} + {String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && ( + <> + + + + )} +
+
+ ) : null}