Medienmanager und Sicherheitsupdate #21
|
|
@ -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 |
|
| 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` |
|
| 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 |
|
| 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` | 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 |
|
| 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 |
|
| 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.
|
**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)
|
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:
|
def transition_to_trash_hidden(cur: Any, conn: Any, asset_id: int, *, set_purge_after: Optional[datetime] = None) -> dict:
|
||||||
now = datetime.now(timezone.utc)
|
now = datetime.now(timezone.utc)
|
||||||
if set_purge_after is None:
|
if set_purge_after is None:
|
||||||
|
|
|
||||||
|
|
@ -2143,47 +2143,72 @@ async def upload_exercise_media(
|
||||||
full_sha = hashlib.sha256(raw).hexdigest()
|
full_sha = hashlib.sha256(raw).hexdigest()
|
||||||
|
|
||||||
cur.execute(
|
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
|
WHERE sha256 = %s AND lower(trim(visibility)) = %s
|
||||||
AND (club_id IS NOT DISTINCT FROM %s)
|
AND (club_id IS NOT DISTINCT FROM %s)
|
||||||
AND lifecycle_state = %s
|
|
||||||
LIMIT 1""",
|
LIMIT 1""",
|
||||||
(full_sha, ex_vis, ex_club, "active"),
|
(full_sha, ex_vis, ex_club),
|
||||||
)
|
)
|
||||||
existing_asset = cur.fetchone()
|
existing_asset = cur.fetchone()
|
||||||
|
|
||||||
if existing_asset:
|
if existing_asset:
|
||||||
ea = r2d(existing_asset)
|
ea = r2d(existing_asset)
|
||||||
aid = ea["id"]
|
lc = (ea.get("lifecycle_state") or "").strip().lower()
|
||||||
sk = ea["storage_key"]
|
if lc == "active":
|
||||||
sz = ea.get("byte_size") or len(raw)
|
aid = ea["id"]
|
||||||
db_path = f"/media/{sk}"
|
sk = ea["storage_key"]
|
||||||
cur.execute(
|
sz = ea.get("byte_size") or len(raw)
|
||||||
f"""INSERT INTO exercise_media (
|
db_path = f"/media/{sk}"
|
||||||
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
cur.execute(
|
||||||
embed_url, embed_platform, title, description, context, is_primary, sort_order,
|
f"""INSERT INTO exercise_media (
|
||||||
media_asset_id
|
exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
||||||
) VALUES (
|
embed_url, embed_platform, title, description, context, is_primary, sort_order,
|
||||||
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s
|
media_asset_id
|
||||||
)
|
) VALUES (
|
||||||
RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename,
|
%s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s
|
||||||
embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at,
|
)
|
||||||
media_asset_id""",
|
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,
|
||||||
exercise_id,
|
media_asset_id""",
|
||||||
media_type,
|
(
|
||||||
db_path,
|
exercise_id,
|
||||||
sz,
|
media_type,
|
||||||
mime,
|
db_path,
|
||||||
file.filename,
|
sz,
|
||||||
title or None,
|
mime,
|
||||||
description or None,
|
file.filename,
|
||||||
context,
|
title or None,
|
||||||
is_primary,
|
description or None,
|
||||||
exercise_id,
|
context,
|
||||||
aid,
|
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:
|
else:
|
||||||
storage_key = f"exercises/{full_sha}{ext}"
|
storage_key = f"exercises/{full_sha}{ext}"
|
||||||
dest_path = path_under_media_root(media_root, storage_key)
|
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):
|
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]:
|
def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]:
|
||||||
|
|
@ -171,5 +171,9 @@ def post_media_asset_lifecycle(
|
||||||
if not ok:
|
if not ok:
|
||||||
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
|
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
|
||||||
return {"ok": True, "purged": asset_id}
|
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")
|
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.status_code == 200
|
||||||
assert r.json() == {"ok": True, "orphan_media_asset_id": None}
|
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
|
# Shinkan Jinkendo Version Information
|
||||||
|
|
||||||
APP_VERSION = "0.8.44"
|
APP_VERSION = "0.8.45"
|
||||||
BUILD_DATE = "2026-05-07"
|
BUILD_DATE = "2026-05-07"
|
||||||
DB_SCHEMA_VERSION = "20260507045"
|
DB_SCHEMA_VERSION = "20260507045"
|
||||||
|
|
||||||
|
|
@ -13,11 +13,11 @@ MODULE_VERSIONS = {
|
||||||
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
"club_join_requests": "1.0.1", # Depends(get_tenant_context)
|
||||||
"admin_users": "1.0.0", # GET /api/admin/users
|
"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)
|
"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",
|
"groups": "0.1.0",
|
||||||
"skills": "0.1.0",
|
"skills": "0.1.0",
|
||||||
"methods": "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_units": "0.2.0",
|
||||||
"training_programs": "0.1.0",
|
"training_programs": "0.1.0",
|
||||||
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
|
||||||
|
|
@ -29,6 +29,14 @@ MODULE_VERSIONS = {
|
||||||
}
|
}
|
||||||
|
|
||||||
CHANGELOG = [
|
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",
|
"version": "0.8.44",
|
||||||
"date": "2026-05-07",
|
"date": "2026-05-07",
|
||||||
|
|
|
||||||
|
|
@ -694,7 +694,44 @@ function ExerciseFormPage() {
|
||||||
setMediaTitle('')
|
setMediaTitle('')
|
||||||
await refreshMedia()
|
await refreshMedia()
|
||||||
} catch (err) {
|
} 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) {
|
if (!response.ok) {
|
||||||
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
const err = await response.json().catch(() => ({ detail: 'Unknown error' }))
|
||||||
const d = err.detail
|
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 =
|
const msg =
|
||||||
typeof d === 'string'
|
typeof d === 'string'
|
||||||
? d
|
? d
|
||||||
: d != null
|
: d != null && typeof d === 'object' && typeof d.message === 'string'
|
||||||
? JSON.stringify(d)
|
? d.message
|
||||||
: `HTTP ${response.status}`
|
: d != null
|
||||||
|
? JSON.stringify(d)
|
||||||
|
: `HTTP ${response.status}`
|
||||||
throw new Error(msg)
|
throw new Error(msg)
|
||||||
}
|
}
|
||||||
return response.json()
|
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) {
|
export async function postMediaAssetLifecycle(assetId, action) {
|
||||||
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
return request(`/api/media-assets/${assetId}/lifecycle`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user