diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 3bc0a40..2221719 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -18,7 +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`) | +| media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | `trash_soft` / `trash_hidden` / `recover` / `purge` / **`reactivate`** (Papierkorb → aktiv); Rechte `assert_can_manage_media_asset_lifecycle` | | media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | Archiv-Liste: nur `lifecycle_state=active`; Sichtbarkeit wie Bibliothek (official / eigenes private / Vereinsmitglied) | | media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | Direkt-Download für Archiv-Thumbs; `?ssetoken`; nur wenn Asset sichtbar | | exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv | @@ -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 — `DELETE …/media/{id}` nur Verknüpfung, optional `orphan_media_asset_id`; übriges siehe Medienzeilen oben. +Letzte Änderung: 2026-05-07 — Upload-Dedupe Papierkorb 409 + `reactivate`; DELETE …/media nur Verknüpfung. --- diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py index 00ca6d9..30ad28e 100644 --- a/backend/media_lifecycle.py +++ b/backend/media_lifecycle.py @@ -108,6 +108,28 @@ def transition_to_trash_soft(cur: Any, conn: Any, asset_id: int) -> dict: return r2d(row) +def reactivate_media_asset_from_trash(cur: Any, conn: Any, asset_id: int) -> dict: + """ + Stufe 1 oder 2 → wieder aktiv (z. B. erneuter Upload derselben Datei / explizite Wiederherstellung). + """ + cur.execute( + """UPDATE media_assets + SET lifecycle_state = %s, updated_at = NOW(), + trash_soft_at = NULL, trash_hidden_at = NULL, purge_after_at = NULL + WHERE id = %s AND lifecycle_state IN (%s, %s) + RETURNING id, lifecycle_state""", + (LC_ACTIVE, asset_id, LC_TRASH_SOFT, LC_TRASH_HIDDEN), + ) + row = cur.fetchone() + if not row: + raise HTTPException( + status_code=400, + detail="Nur Medien aus dem Papierkorb (Stufe 1 oder 2) können reaktiviert werden", + ) + 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: diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 5d295fc..d3571b4 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -2143,47 +2143,72 @@ async def upload_exercise_media( full_sha = hashlib.sha256(raw).hexdigest() cur.execute( - """SELECT id, storage_key, byte_size FROM media_assets + """SELECT id, storage_key, byte_size, lifecycle_state, original_filename FROM media_assets WHERE sha256 = %s AND lower(trim(visibility)) = %s AND (club_id IS NOT DISTINCT FROM %s) - AND lifecycle_state = %s LIMIT 1""", - (full_sha, ex_vis, ex_club, "active"), + (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, - 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, - db_path, - sz, - mime, - file.filename, - title or None, - description or None, - context, - is_primary, - exercise_id, - aid, - ), - ) + lc = (ea.get("lifecycle_state") or "").strip().lower() + if lc == "active": + 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, + 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, + db_path, + sz, + mime, + file.filename, + title or None, + description or None, + context, + is_primary, + exercise_id, + aid, + ), + ) + elif lc in ("trash_soft", "trash_hidden"): + raise HTTPException( + status_code=409, + detail={ + "code": "MEDIA_ASSET_IN_TRASH", + "message": ( + "Diese Datei ist inhaltsgleich (SHA-256) mit einem Archiv-Medium im Papierkorb. " + "Reaktivieren und verknüpfen ist möglich; ein zweites physisches Exemplar wird nicht angelegt." + ), + "media_asset_id": ea["id"], + "lifecycle_state": lc, + "original_filename": ea.get("original_filename"), + }, + ) + else: + raise HTTPException( + status_code=409, + detail={ + "code": "MEDIA_ASSET_UNAVAILABLE", + "message": "Es existiert bereits ein Archiv-Eintrag zu dieser Datei in einem nicht nutzbaren Zustand.", + "media_asset_id": ea["id"], + "lifecycle_state": lc, + }, + ) else: storage_key = f"exercises/{full_sha}{ext}" dest_path = path_under_media_root(media_root, storage_key) diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index b4eb10a..872d3b5 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -17,7 +17,7 @@ router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) class MediaLifecycleBody(BaseModel): - action: Literal["trash_soft", "trash_hidden", "recover", "purge"] + action: Literal["trash_soft", "trash_hidden", "recover", "purge", "reactivate"] def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: @@ -171,5 +171,9 @@ def post_media_asset_lifecycle( if not ok: raise HTTPException(status_code=400, detail="Löschen nicht möglich") return {"ok": True, "purged": asset_id} + if action == "reactivate": + from media_lifecycle import reactivate_media_asset_from_trash + + return reactivate_media_asset_from_trash(cur, conn, asset_id) raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index f558d4d..e44609d 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -271,3 +271,44 @@ def test_delete_exercise_media_embed_row_no_orphan(client: TestClient) -> None: assert r.status_code == 200 assert r.json() == {"ok": True, "orphan_media_asset_id": None} + + +def test_media_asset_lifecycle_reactivate_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=[], + ) + + mock_cur = MagicMock() + mock_cur.fetchone.side_effect = [ + { + "id": 5, + "visibility": "private", + "club_id": None, + "uploaded_by_profile_id": 1, + "lifecycle_state": "trash_soft", + "storage_key": "exercises/a.mp4", + "storage_backend": "local", + "trash_soft_at": None, + "trash_hidden_at": None, + "purge_after_at": None, + }, + {"id": 5, "lifecycle_state": "active"}, + ] + 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.post( + "/api/media-assets/5/lifecycle", + headers={"X-Auth-Token": "t", "Content-Type": "application/json"}, + json={"action": "reactivate"}, + ) + + assert r.status_code == 200 + assert r.json()["lifecycle_state"] == "active" diff --git a/backend/version.py b/backend/version.py index 47ef902..7565f0e 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.44" +APP_VERSION = "0.8.45" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260507045" @@ -13,11 +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.1.0", # GET Liste + GET …/file (Archiv); POST …/lifecycle + "media_assets": "1.2.0", # lifecycle action reactivate (Papierkorb → aktiv) "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.14.0", # DELETE media: nur Verknüpfung; optional orphan_media_asset_id + "exercises": "2.15.0", # Upload: 409 MEDIA_ASSET_IN_TRASH bei Papierkorb-Dedupe (SHA + visibility + club) "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.45", + "date": "2026-05-07", + "changes": [ + "Upload Übungsmedien: gleicher Inhalt (SHA-256) wie Papierkorb-Asset → 409 MEDIA_ASSET_IN_TRASH statt DB-Fehler; Lifecycle action reactivate (trash_soft/hidden → active)", + "Frontend: Dialog Reaktivieren + Verknüpfen; uploadExerciseMedia wertet strukturiertes 409 aus", + ], + }, { "version": "0.8.44", "date": "2026-05-07", diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index a4acd0d..edb3ec0 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -694,7 +694,44 @@ function ExerciseFormPage() { setMediaTitle('') await refreshMedia() } catch (err) { - alert('Upload: ' + err.message) + if (err.code === 'MEDIA_ASSET_IN_TRASH' && err.payload?.media_asset_id != null) { + const aid = err.payload.media_asset_id + const nameHint = + (mediaFile && mediaFile.name) || + err.payload.original_filename || + 'diese Datei' + if ( + confirm( + `Die hochgeladene Datei ist inhaltsgleich mit einem Archiv-Medium im Papierkorb (${nameHint}). ` + + 'Soll dieses Medium wieder aktiviert und an die Übung gehängt werden? (Es wird kein zweites Exemplar auf der Platte angelegt.)', + ) + ) { + try { + await api.postMediaAssetLifecycle(aid, 'reactivate') + await api.attachExerciseMediaFromAsset(exerciseId, { + media_asset_id: aid, + title: mediaTitle || undefined, + description: '', + context: mediaContext, + is_primary: false, + }) + setMediaFile(null) + setMediaTitle('') + await refreshMedia() + } catch (e2) { + alert(e2.message || String(e2)) + } + } + return + } + if (err.code === 'MEDIA_ASSET_UNAVAILABLE') { + alert( + (err.message || 'Archiv-Konflikt') + + ' Bitte wenden Sie sich an einen Administrator oder wählen Sie eine andere Datei.', + ) + return + } + alert('Upload: ' + (err.message || String(err))) } } diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index c16ee30..15bf102 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -473,12 +473,29 @@ export async function uploadExerciseMedia(exerciseId, formData) { if (!response.ok) { const err = await response.json().catch(() => ({ detail: 'Unknown error' })) const d = err.detail + if ( + response.status === 409 && + d && + typeof d === 'object' && + !Array.isArray(d) && + typeof d.code === 'string' + ) { + const e = new Error( + typeof d.message === 'string' ? d.message : 'Upload konnte nicht verarbeitet werden', + ) + e.code = d.code + e.status = 409 + e.payload = d + throw e + } const msg = typeof d === 'string' ? d - : d != null - ? JSON.stringify(d) - : `HTTP ${response.status}` + : d != null && typeof d === 'object' && typeof d.message === 'string' + ? d.message + : d != null + ? JSON.stringify(d) + : `HTTP ${response.status}` throw new Error(msg) } return response.json() @@ -502,7 +519,7 @@ export async function reorderExerciseMedia(exerciseId, mediaIds) { }) } -/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge */ +/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */ export async function postMediaAssetLifecycle(assetId, action) { return request(`/api/media-assets/${assetId}/lifecycle`, { method: 'POST',