feat: update media management and project status documentation
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s
All checks were successful
Deploy Development / deploy (push) Successful in 38s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 27s
- Updated project status to reflect the latest media management milestones and version increment to 0.8.44. - Enhanced MEDIA_ASSETS_AND_ARCHIVE_SPEC.md with new API details for media asset lifecycle and inline media integration. - Improved exercise media handling in the frontend, including new preview features and user prompts for media deletion. - Adjusted backend API to ensure proper handling of media asset deletions without removing files, maintaining governance and user experience.
This commit is contained in:
parent
631ba1cb43
commit
e2964a077d
|
|
@ -1,34 +1,29 @@
|
|||
# Shinkan Jinkendo - Projekt-Status
|
||||
|
||||
**Stand:** 2026-05-05
|
||||
**Version (Code):** 0.8.10 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260505037`
|
||||
**Stand:** 2026-05-07
|
||||
**Version (Code):** 0.8.43 (`backend/version.py`, APP_VERSION)
|
||||
**DB-Schema-Version:** `20260507045` (u. a. `media_assets`, `platform_media_storage`)
|
||||
**Branch:** develop
|
||||
|
||||
---
|
||||
|
||||
## Executive Summary
|
||||
|
||||
**Aktueller Meilenstein:** **Trainingsrahmenprogramm Bibliothek + Slot‑Blueprint** (DB **036–037**): Rahmenkopf nur als Vorlage mit Kontext‑Stammdaten; pro Slot genau eine **Blueprint‑`training_unit`** mit **`framework_unit_sections`/`_items`** wie die Planung; Kalenderliste blendet Blueprints aus; **`POST /api/training-units/from-framework-slot`** materialisiert Kopien mit **`origin_framework_slot_id`**. Parallel: **Progressionsgraph** (032–034) bleibt unterstützend (**`TRAINING_FRAMEWORK_SPEC.md`** §3–§4).
|
||||
**Aktueller Meilenstein (Medien):** **Archiv & Wiederverwendung** auf Basis von **Migration 045** (`media_assets`, `exercise_media.media_asset_id`, Superadmin-Speicherpfad). **Papierkorb §5** (Lifecycle-API, Retention-Job), **Archiv-Liste** (`GET /api/media-assets`), **Direkt-Download** für Thumbnails (`GET /api/media-assets/{id}/file`), **Verknüpfen ohne Upload** (`POST /api/exercises/{id}/media/from-asset`), Übungsformular mit Archiv-Dialog und Vorschau sind umgesetzt.
|
||||
|
||||
**Letzte dokumentierte Änderungen (Mai 2026):**
|
||||
**Parallel weiter relevant:** **Trainingsrahmenprogramm** (036–037), **Progressionsgraph** (032–034) — siehe **`TRAINING_FRAMEWORK_SPEC.md`**.
|
||||
|
||||
- ✅ Migration **036:** Rahmen nur Bibliothek; Fokus/Stil + M:N Trainingsarten/Zielgruppen; Entfall `plan_mode`/`group_id` am Kopf.
|
||||
- ✅ Migration **037:** `training_units.framework_slot_id` / `origin_framework_slot_id`; Migration Entfall **`training_framework_slot_exercises`**.
|
||||
- ✅ APIs: erweiterte Rahmen‑Hydration (`sections`, `exercises`, `blueprint_training_unit_id`); Planung siehe **`TRAINING_FRAMEWORK_SPEC.md`** §2.4.
|
||||
- ✅ Frontend: `createTrainingUnitFromFrameworkSlot` in `api.js`.
|
||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Medien-Norm: [`technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`](technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md)
|
||||
|
||||
**Referenz:** [`library/FEATURES_DELIVERED_2026-Q2.md`](library/FEATURES_DELIVERED_2026-Q2.md) · Rahmen/Graph: [`technical/TRAINING_FRAMEWORK_SPEC.md`](technical/TRAINING_FRAMEWORK_SPEC.md)
|
||||
**Nächste Schritte — Medien & Archiv** (neu priorisiert, Stand 2026-05-07):
|
||||
|
||||
**Nächste Schritte — Medien & Archiv** (siehe `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md`):
|
||||
1. **Übung → `official` Promotion** inkl. Medien-Anhebung + **Copyright-Pflicht** bei `official` (Spec §4.2) — höchste fachliche Lücke zur Governance.
|
||||
2. **Eigenständige Medienmanager-Seite** (Admin/Verein): Metadaten/Copyright bearbeiten, Filtern nach Lifecycle, Bulk — ergänzt den in der Übung eingebetteten Archiv-Picker.
|
||||
3. **Tests & Observability:** gezielte pytest-Abdeckung für Archiv/Verknüpfen; optional Retention-Job-Dry-Run dokumentieren.
|
||||
4. **S3 / externes Backend** hinter Speicher-Abstraktion (Spec §7) — nach stabiler Nutzung lokaler/NAS-Pfade.
|
||||
5. **Inline-Medien im Fließtext** (Spec §11) — bewusst **nach** Promotion/Copyright und tragfähigem Archiv-Workflow.
|
||||
|
||||
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.
|
||||
**Inline:** Leitplanken in **`MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` §11**; kein Big-Bang vor stabiler Archiv-/Governance-Basis.
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -55,6 +50,7 @@
|
|||
| **030** | **training_unit_exercises.exercise_variant_id** | ✅ | 🔲 |
|
||||
| **032–034** | **Progressionsgraph Übung→Übung** | ✅ | 🔲 |
|
||||
| **035–037** | **Rahmenprogramm, Bibliothek‑Kopf, Slot‑Blueprint‑Units** | ✅ | 🔲 |
|
||||
| **040–045** | **u. a. Mitgliedschaften, Übungs-Governance, `media_assets`, Plattform-Speicherpfad** | ✅ | 🔲 |
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -107,8 +103,10 @@ 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**)
|
||||
- [x] **Medien:** Papierkorb (§5), Retention-Job, Archiv-API, „Aus Archiv verknüpfen“, Picker/Vorschau in Übungsbearbeitung (Release 0.8.42 ff.)
|
||||
- [ ] **Medien:** Promotion Übung↔Medien + Copyright-Pflicht `official` (Spec §4.2)
|
||||
- [ ] **Medien:** Eigenständige Medienmanager-UI (Admin/Verein), S3 — Roadmap Executive Summary
|
||||
- [ ] **Medien:** Inline im Fließtext — nach Spec §11, nach Promotion/Archiv-Reife
|
||||
- [ ] Admin-UI für Skill-Kategorien (CRUD) – falls noch offen
|
||||
- [ ] Responsive Design / Dark Mode / PWA
|
||||
- [ ] KI-Suche (`ai_search`) über reine Volltextsuche hinaus
|
||||
|
|
@ -139,7 +137,7 @@ Die exakten Zahlen hängen von der Umgebung ab (siehe Admin/DB). Die Skills/Übu
|
|||
|
||||
### Dev
|
||||
|
||||
Branch `develop`; Migrations bis mindestens **037** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
Branch `develop`; Migrations bis mindestens **045** auf dem aktuellen Entwicklungsstand; Details in `backend/version.py`.
|
||||
|
||||
### Prod
|
||||
|
||||
|
|
@ -161,7 +159,7 @@ Deployment der oben genannten Migrationen und Datenabgleich nach internem Prozes
|
|||
| Search & Filter | `technical/SEARCH_FILTER_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Liste UX) |
|
||||
| Media Upload | `technical/MEDIA_UPLOAD_SPEC.md` | 2026-04-27 | ✅ Aktualisiert (Limits) |
|
||||
| Medien-Archiv & Lifecycle | `technical/MEDIA_ASSETS_AND_ARCHIVE_SPEC.md` | 2026-05-07 | ✅ §11 Inline-Plan, Drift-Tab |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Diese Datei |
|
||||
| Projektstatus | `PROJECT_STATUS.md` | 2026-05-07 | ✅ Medien-Meilenstein aktualisiert |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -188,7 +188,7 @@ Das widerspricht **nicht** dem Papierkorb: wer kein Recht hat, ein Asset **globa
|
|||
|-------|----------|
|
||||
| 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. |
|
||||
| 2026-05-07 | Archiv-API: `GET /api/media-assets`, `GET /api/media-assets/{id}/file`; `POST /api/exercises/{id}/media/from-asset`; UI Picker/Vorschau. Papierkorb/Retention wie vorhergehende Releases. |
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -33,7 +33,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 — Medienarchiv `GET /api/media-assets`, `GET …/file`, Übung `POST …/media/from-asset`; Lifecycle siehe oben.
|
||||
Letzte Änderung: 2026-05-07 — `DELETE …/media/{id}` nur Verknüpfung, optional `orphan_media_asset_id`; übriges siehe Medienzeilen oben.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -2433,16 +2433,19 @@ def delete_exercise_media(
|
|||
media_id: int,
|
||||
tenant: TenantContext = Depends(get_tenant_context),
|
||||
):
|
||||
"""
|
||||
Entfernt nur die Verknüpfung Übung ↔ exercise_media (bzw. Embed-Zeile).
|
||||
Löscht keine Datei und keinen media_assets-Datensatz — optionales Papierkorb-Angebot nur clientseitig
|
||||
über orphan_media_asset_id, wenn dies die letzte Referenz auf das Asset war.
|
||||
"""
|
||||
profile_id = tenant.profile_id
|
||||
unlink_path: Optional[Path] = None
|
||||
orphan_media_asset_id: Optional[int] = None
|
||||
with get_db() as conn:
|
||||
cur = get_cursor(conn)
|
||||
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||
media_root = get_effective_media_root(cur)
|
||||
cur.execute(
|
||||
"""SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key
|
||||
"""SELECT em.media_asset_id
|
||||
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),
|
||||
)
|
||||
|
|
@ -2450,41 +2453,19 @@ def delete_exercise_media(
|
|||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||
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)
|
||||
if int(r2d(cur.fetchone())["c"]) <= 1:
|
||||
orphan_media_asset_id = int(media_asset_id)
|
||||
|
||||
cur.execute(
|
||||
"DELETE FROM exercise_media WHERE id = %s AND exercise_id = %s",
|
||||
(media_id, exercise_id),
|
||||
)
|
||||
conn.commit()
|
||||
|
||||
if unlink_path and unlink_path.is_file():
|
||||
try:
|
||||
unlink_path.unlink()
|
||||
except OSError as e:
|
||||
logger.warning("Medien-Datei konnte nicht gelöscht werden: %s", e)
|
||||
|
||||
return {"ok": True}
|
||||
return {"ok": True, "orphan_media_asset_id": orphan_media_asset_id}
|
||||
|
|
|
|||
273
backend/tests/test_media_assets_archive.py
Normal file
273
backend/tests/test_media_assets_archive.py
Normal file
|
|
@ -0,0 +1,273 @@
|
|||
"""
|
||||
Medienarchiv: GET /api/media-assets und POST /api/exercises/{id}/media/from-asset (gemockte DB).
|
||||
"""
|
||||
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
|
||||
from tenant_context import TenantContext, get_tenant_context
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clear_overrides() -> None:
|
||||
yield
|
||||
app.dependency_overrides.pop(require_auth, None)
|
||||
app.dependency_overrides.pop(get_tenant_context, None)
|
||||
|
||||
|
||||
def _mock_db(mock_cur: MagicMock) -> MagicMock:
|
||||
mock_conn = MagicMock()
|
||||
mock_cm = MagicMock()
|
||||
mock_cm.__enter__.return_value = mock_conn
|
||||
mock_cm.__exit__.return_value = False
|
||||
return mock_cm
|
||||
|
||||
|
||||
def test_list_media_assets_ok_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=10,
|
||||
global_role="trainer",
|
||||
effective_club_id=5,
|
||||
club_ids=frozenset({5}),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchall.return_value = [
|
||||
{
|
||||
"id": 1,
|
||||
"mime_type": "image/png",
|
||||
"byte_size": 100,
|
||||
"original_filename": "a.png",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 2,
|
||||
"lifecycle_state": "active",
|
||||
"created_at": None,
|
||||
"sha256": "a" * 64,
|
||||
}
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
|
||||
"routers.media_assets.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["limit"] == 30
|
||||
assert len(body["items"]) == 1
|
||||
assert body["items"][0]["original_filename"] == "a.png"
|
||||
|
||||
|
||||
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"c": 0},
|
||||
{
|
||||
"id": 5,
|
||||
"mime_type": "image/jpeg",
|
||||
"byte_size": 10,
|
||||
"original_filename": "x.jpg",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"storage_key": "exercises/x.jpg",
|
||||
},
|
||||
{"id": 1},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/exercises/3/media/from-asset",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"media_asset_id": 5,
|
||||
"title": "",
|
||||
"description": "",
|
||||
"context": "ablauf",
|
||||
"is_primary": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 400
|
||||
assert "bereits" in (r.json().get("detail") or "").lower()
|
||||
|
||||
|
||||
def test_attach_from_asset_ok_mocked(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
inserted = {
|
||||
"id": 99,
|
||||
"exercise_id": 3,
|
||||
"media_type": "image",
|
||||
"file_path": "/media/exercises/h.jpg",
|
||||
"file_size": 10,
|
||||
"mime_type": "image/jpeg",
|
||||
"original_filename": "h.jpg",
|
||||
"embed_url": None,
|
||||
"embed_platform": None,
|
||||
"title": "h.jpg",
|
||||
"description": None,
|
||||
"sort_order": 1,
|
||||
"is_primary": False,
|
||||
"context": "ablauf",
|
||||
"created_at": None,
|
||||
"media_asset_id": 5,
|
||||
}
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"c": 0},
|
||||
{
|
||||
"id": 5,
|
||||
"mime_type": "image/jpeg",
|
||||
"byte_size": 10,
|
||||
"original_filename": "h.jpg",
|
||||
"visibility": "official",
|
||||
"club_id": None,
|
||||
"uploaded_by_profile_id": 1,
|
||||
"lifecycle_state": "active",
|
||||
"storage_key": "exercises/h.jpg",
|
||||
},
|
||||
None,
|
||||
inserted,
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.post(
|
||||
"/api/exercises/3/media/from-asset",
|
||||
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
|
||||
json={
|
||||
"media_asset_id": 5,
|
||||
"context": "detail",
|
||||
"is_primary": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert r.status_code == 201
|
||||
body = r.json()
|
||||
assert body["id"] == 99
|
||||
assert body["media_asset_id"] == 5
|
||||
assert body["asset_lifecycle_state"] == "active"
|
||||
|
||||
|
||||
def test_delete_exercise_media_returns_orphan_when_last_ref(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": 88},
|
||||
{"c": 1},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
assert body["ok"] is True
|
||||
assert body["orphan_media_asset_id"] == 88
|
||||
|
||||
|
||||
def test_delete_exercise_media_no_orphan_when_shared(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": 88},
|
||||
{"c": 2},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/20", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json()["orphan_media_asset_id"] is None
|
||||
|
||||
|
||||
def test_delete_exercise_media_embed_row_no_orphan(client: TestClient) -> None:
|
||||
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "trainer"}
|
||||
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
|
||||
profile_id=1,
|
||||
global_role="trainer",
|
||||
effective_club_id=None,
|
||||
club_ids=frozenset(),
|
||||
memberships=[],
|
||||
)
|
||||
|
||||
mock_cur = MagicMock()
|
||||
mock_cur.fetchone.side_effect = [
|
||||
{"created_by": 1, "visibility": "private", "club_id": None},
|
||||
{"media_asset_id": None},
|
||||
]
|
||||
mock_cm = _mock_db(mock_cur)
|
||||
|
||||
with patch("routers.exercises.get_db", return_value=mock_cm), patch(
|
||||
"routers.exercises.get_cursor", return_value=mock_cur
|
||||
):
|
||||
r = client.delete("/api/exercises/10/media/21", headers={"X-Auth-Token": "t"})
|
||||
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True, "orphan_media_asset_id": None}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
# Shinkan Jinkendo Version Information
|
||||
|
||||
APP_VERSION = "0.8.43"
|
||||
APP_VERSION = "0.8.44"
|
||||
BUILD_DATE = "2026-05-07"
|
||||
DB_SCHEMA_VERSION = "20260507045"
|
||||
|
||||
|
|
@ -17,7 +17,7 @@ MODULE_VERSIONS = {
|
|||
"groups": "0.1.0",
|
||||
"skills": "0.1.0",
|
||||
"methods": "0.1.0",
|
||||
"exercises": "2.13.0", # POST /media/from-asset (Archiv verknüpfen)
|
||||
"exercises": "2.14.0", # DELETE media: nur Verknüpfung; optional orphan_media_asset_id
|
||||
"training_units": "0.2.0",
|
||||
"training_programs": "0.1.0",
|
||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||
|
|
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
|
|||
}
|
||||
|
||||
CHANGELOG = [
|
||||
{
|
||||
"version": "0.8.44",
|
||||
"date": "2026-05-07",
|
||||
"changes": [
|
||||
"DELETE /api/exercises/{id}/media/{mid}: entfernt nur exercise_media; keine Datei-/media_assets-Löschung; Response orphan_media_asset_id wenn letzte Referenz",
|
||||
"Übung bearbeiten: Video-Kachel (Erstframe), Dateiname; Papierkorb-Schalter entfernt; „Aus Übung entfernen“ + optional Papierkorb bei Waise",
|
||||
],
|
||||
},
|
||||
{
|
||||
"version": "0.8.43",
|
||||
"date": "2026-05-07",
|
||||
|
|
|
|||
|
|
@ -6,6 +6,70 @@ import RichTextEditor from '../components/RichTextEditor'
|
|||
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
|
||||
import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels'
|
||||
|
||||
/** Kachelvorschau: Video nutzt ersten Frame (metadata), Bild = img. */
|
||||
function ExerciseMediaThumbTile({ exerciseId, media, onOpenPreview }) {
|
||||
const src = !media.embed_url ? resolveExerciseMediaFileUrl(exerciseId, media) : null
|
||||
const commonStyle = {
|
||||
width: '100%',
|
||||
height: '100%',
|
||||
objectFit: 'cover',
|
||||
}
|
||||
return (
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
title="Vorschau"
|
||||
onClick={() => onOpenPreview(media)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault()
|
||||
onOpenPreview(media)
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
flexShrink: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||||
border: '1px solid var(--border)',
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{media.embed_url ? (
|
||||
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
||||
{media.embed_platform || 'Embed'}
|
||||
</span>
|
||||
) : (media.mime_type?.startsWith('image/') || media.media_type === 'image') && src ? (
|
||||
<img alt="" src={src} style={commonStyle} />
|
||||
) : (media.mime_type?.startsWith('video/') || media.media_type === 'video') && src ? (
|
||||
<video
|
||||
src={src}
|
||||
muted
|
||||
playsInline
|
||||
preload="metadata"
|
||||
style={{ ...commonStyle, pointerEvents: 'none' }}
|
||||
onLoadedMetadata={(e) => {
|
||||
try {
|
||||
const el = e.currentTarget
|
||||
const d = el.duration
|
||||
el.currentTime = Number.isFinite(d) && d > 0 ? Math.min(0.05, d * 0.01) : 0.05
|
||||
} catch (_) {
|
||||
/* ignore */
|
||||
}
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const INTENSITY_OPTIONS = [
|
||||
{ value: '', label: '—' },
|
||||
{ value: 'niedrig', label: 'niedrig' },
|
||||
|
|
@ -657,25 +721,32 @@ function ExerciseFormPage() {
|
|||
}
|
||||
|
||||
const handleDeleteMedia = async (mid) => {
|
||||
if (!confirm('Medium löschen?')) return
|
||||
if (
|
||||
!confirm(
|
||||
'Dieses Medium aus der Übung entfernen? Nur die Verknüpfung wird gelöscht. Die Datei bleibt im Archiv, solange sie noch woanders genutzt wird.',
|
||||
)
|
||||
) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
await api.deleteExerciseMedia(exerciseId, mid)
|
||||
const res = await api.deleteExerciseMedia(exerciseId, mid)
|
||||
await refreshMedia()
|
||||
const oid = res?.orphan_media_asset_id
|
||||
if (oid != null) {
|
||||
if (
|
||||
confirm(
|
||||
'Dieses Archiv-Medium wird danach nirgendwo mehr verwendet. In den Papierkorb (Stufe 1) legen? (Später in der Medienverwaltung wiederherstellbar.)',
|
||||
)
|
||||
) {
|
||||
await api.postMediaAssetLifecycle(oid, 'trash_soft')
|
||||
await refreshMedia()
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
alert(err.message)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
|
|
@ -1282,8 +1353,8 @@ function ExerciseFormPage() {
|
|||
<div className="card" style={{ marginTop: '16px' }}>
|
||||
<h2 style={{ marginTop: 0, fontSize: '1.1rem' }}>Medien</h2>
|
||||
<p style={{ color: 'var(--text2)', fontSize: '13px' }}>
|
||||
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien können mehrfach
|
||||
verknüpft werden.
|
||||
Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien mehrfach nutzbar.
|
||||
Papierkorb und Freigaben steuern Sie zentral im Archiv — hier nur Verknüpfung zur Übung.
|
||||
</p>
|
||||
<div
|
||||
style={{
|
||||
|
|
@ -1375,43 +1446,7 @@ function ExerciseFormPage() {
|
|||
alignItems: 'flex-start',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
title="Vorschau"
|
||||
onClick={() => setMediaPreview(m)}
|
||||
style={{
|
||||
width: 72,
|
||||
height: 72,
|
||||
flexShrink: 0,
|
||||
borderRadius: '8px',
|
||||
overflow: 'hidden',
|
||||
background: 'var(--surface2, rgba(127,127,127,0.12))',
|
||||
border: '1px solid var(--border)',
|
||||
padding: 0,
|
||||
cursor: 'pointer',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
{m.embed_url ? (
|
||||
<span style={{ fontSize: '11px', padding: '4px', color: 'var(--text2)', textAlign: 'center' }}>
|
||||
{m.embed_platform || 'Embed'}
|
||||
</span>
|
||||
) : m.mime_type?.startsWith('image/') ? (
|
||||
<img
|
||||
alt=""
|
||||
src={resolveExerciseMediaFileUrl(exerciseId, m)}
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
/>
|
||||
) : m.mime_type?.startsWith('video/') ? (
|
||||
<span style={{ fontSize: '22px', opacity: 0.75 }} aria-hidden>
|
||||
▶
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: '11px', color: 'var(--text2)' }}>Datei</span>
|
||||
)}
|
||||
</button>
|
||||
<ExerciseMediaThumbTile exerciseId={exerciseId} media={m} onOpenPreview={setMediaPreview} />
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
|
||||
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
|
||||
|
|
@ -1443,82 +1478,32 @@ function ExerciseFormPage() {
|
|||
</>
|
||||
)}
|
||||
</div>
|
||||
{m.media_asset_id ? (
|
||||
<div style={{ marginTop: '8px', fontSize: '12px' }}>
|
||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
|
||||
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
|
||||
Papierkorb (Stufe 1): für neue Zuordnungen gesperrt.
|
||||
</p>
|
||||
)}
|
||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
|
||||
<p style={{ color: 'var(--danger)', margin: '0 0 6px' }}>
|
||||
Ausgeblendet (Stufe 2): in der Übungsansicht nicht sichtbar.
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '6px', alignItems: 'center' }}>
|
||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'active' && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => {
|
||||
if (confirm('Medium in den Papierkorb (Stufe 1)?')) {
|
||||
runMediaLifecycle(m.media_asset_id, 'trash_soft')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Papierkorb (Stufe 1)
|
||||
</button>
|
||||
)}
|
||||
{['active', 'trash_soft'].includes(String(m.asset_lifecycle_state || 'active').toLowerCase()) && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => {
|
||||
if (confirm('Medium ausblenden (Stufe 2)? Lesende Nutzer sehen es nicht mehr.')) {
|
||||
runMediaLifecycle(m.media_asset_id, 'trash_hidden')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Ausblenden (Stufe 2)
|
||||
</button>
|
||||
)}
|
||||
{String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-secondary"
|
||||
style={{ fontSize: '11px', padding: '4px 8px' }}
|
||||
onClick={() => runMediaLifecycle(m.media_asset_id, 'recover')}
|
||||
>
|
||||
Wiederherstellen (Stufe 1)
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
style={{
|
||||
fontSize: '11px',
|
||||
padding: '4px 8px',
|
||||
background: 'var(--danger)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
}}
|
||||
onClick={() => {
|
||||
if (
|
||||
confirm('Endgültig löschen? Entfernt Datei und Verknüpfungen dieser Medien-ID.')
|
||||
) {
|
||||
runMediaLifecycle(m.media_asset_id, 'purge')
|
||||
}
|
||||
}}
|
||||
>
|
||||
Endgültig löschen
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
style={{
|
||||
fontSize: '12px',
|
||||
color: 'var(--text2)',
|
||||
marginTop: '6px',
|
||||
wordBreak: 'break-word',
|
||||
lineHeight: 1.35,
|
||||
}}
|
||||
>
|
||||
{(m.original_filename || '').trim() ||
|
||||
(m.title || '').trim() ||
|
||||
(m.embed_url ? m.embed_url : '') ||
|
||||
'—'}
|
||||
</div>
|
||||
{m.media_asset_id &&
|
||||
String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_soft' && (
|
||||
<p style={{ color: 'var(--danger)', margin: '8px 0 0', fontSize: '12px' }}>
|
||||
Hinweis: Dieses Archiv-Medium ist im Papierkorb (Stufe 1).
|
||||
</p>
|
||||
)}
|
||||
{m.media_asset_id &&
|
||||
String(m.asset_lifecycle_state || 'active').toLowerCase() === 'trash_hidden' && (
|
||||
<p style={{ color: 'var(--danger)', margin: '8px 0 0', fontSize: '12px' }}>
|
||||
Hinweis: Archiv-Medium ausgeblendet (Stufe 2) — in der Übungsansicht unsichtbar.
|
||||
</p>
|
||||
)}
|
||||
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
|
||||
<input
|
||||
type="text"
|
||||
|
|
@ -1562,18 +1547,15 @@ function ExerciseFormPage() {
|
|||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn"
|
||||
className="btn btn-secondary"
|
||||
style={{
|
||||
marginTop: '8px',
|
||||
fontSize: '11px',
|
||||
padding: '4px 10px',
|
||||
background: 'var(--danger)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
fontSize: '12px',
|
||||
padding: '6px 12px',
|
||||
}}
|
||||
onClick={() => handleDeleteMedia(m.id)}
|
||||
>
|
||||
Löschen
|
||||
Aus Übung entfernen
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
|
|
@ -1740,13 +1722,13 @@ function ExerciseFormPage() {
|
|||
{mediaPreview.embed_url}
|
||||
</a>
|
||||
</p>
|
||||
) : mediaPreview.mime_type?.startsWith('video/') ? (
|
||||
) : mediaPreview.mime_type?.startsWith('video/') || mediaPreview.media_type === 'video' ? (
|
||||
<video
|
||||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||||
controls
|
||||
style={{ width: '100%', borderRadius: '8px', maxHeight: '70vh' }}
|
||||
/>
|
||||
) : mediaPreview.mime_type?.startsWith('image/') ? (
|
||||
) : mediaPreview.mime_type?.startsWith('image/') || mediaPreview.media_type === 'image' ? (
|
||||
<img
|
||||
alt=""
|
||||
src={resolveExerciseMediaFileUrl(exerciseId, mediaPreview)}
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user