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
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:
parent
e2964a077d
commit
da368222e0
|
|
@ -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.
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user