feat: implement media asset reactivation and enhance lifecycle management
All checks were successful
Deploy Development / deploy (push) Successful in 34s
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 31s

- Added a new action to the media asset lifecycle for reactivating assets from the trash, allowing users to restore previously deleted media.
- Updated the backend API to handle reactivation requests and ensure proper state transitions for media assets.
- Enhanced frontend error handling to prompt users for reactivation when attempting to upload media that matches an existing asset in the trash.
- Incremented version to 0.8.45, reflecting the latest changes in media lifecycle management and user experience improvements.
This commit is contained in:
Lars 2026-05-07 13:25:42 +02:00
parent e2964a077d
commit da368222e0
8 changed files with 198 additions and 44 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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