Medienmanager und Sicherheitsupdate #21

Merged
Lars merged 15 commits from develop into main 2026-05-07 16:00:19 +02:00
9 changed files with 1650 additions and 358 deletions
Showing only changes of commit b8453f3f07 - Show all commits

View File

@ -18,9 +18,11 @@ 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 | | 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 | `trash_soft` / `trash_hidden` / `recover` / `purge` / **`reactivate`** (Papierkorb → aktiv); Rechte `assert_can_manage_media_asset_lifecycle` | | media_assets | `POST /api/media-assets/{id}/lifecycle` | ja | `get_tenant_context` | ja | u. a. `trash_soft` mit Trainer-nur-privat-Eigentum; `purge` nur **Superadmin**; Superadmin: `superadmin_force_lifecycle`, `superadmin_hard_delete` |
| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets | | media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | optional `lifecycle`; Standard `active`; Liste inkl. `copyright_notice`; Papierkorb-Ansicht nur sichtbare Mandanten-Assets |
| media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Metadaten (Copyright); `assert_can_manage_media_asset_lifecycle` | | media_assets | `POST /api/media-assets/bulk-lifecycle` | ja | `get_tenant_context` | ja | Mehrfach-Lifecycle; gleiche Regeln wie Einzel-POST |
| media_assets | `POST /api/media-assets/bulk-patch` | ja | `get_tenant_context` | ja | Copyright / Bezeichner / Sichtbarkeit für viele IDs; gemischte Fehler in `failed[]` |
| media_assets | `PATCH /api/media-assets/{id}` | ja | `get_tenant_context` | ja | Copyright, `original_filename`, optional `visibility`/`club_id`; Rechte pro Stufe |
| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung | | media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | aktiv: Bibliotheks-Sichtbarkeit; `trash_soft`/`trash_hidden`: wie Lifecycle-Verwaltung |
| 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 |
| auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | | auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT |

View File

@ -11,6 +11,10 @@ def is_platform_admin(role: Optional[str]) -> bool:
return (role or "").lower() in ("admin", "superadmin") return (role or "").lower() in ("admin", "superadmin")
def is_superadmin(role: Optional[str]) -> bool:
return (role or "").lower() == "superadmin"
def club_ids_for_profile(cur, profile_id: int) -> Set[int]: def club_ids_for_profile(cur, profile_id: int) -> Set[int]:
cur.execute( cur.execute(
""" """
@ -125,6 +129,24 @@ def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> Li
return out return out
def club_ids_for_profile_with_roles(cur, profile_id: int, *role_codes: str) -> Set[int]:
"""Vereins-IDs, in denen das Profil mindestens eine der Rollen hat."""
if not role_codes:
return set()
ph = ",".join(["%s"] * len(role_codes))
cur.execute(
f"""
SELECT DISTINCT cm.club_id
FROM club_members cm
INNER JOIN club_member_roles r ON r.club_member_id = cm.id
WHERE cm.profile_id = %s AND cm.status = 'active'
AND r.role_code IN ({ph})
""",
(profile_id, *role_codes),
)
return {int(r["club_id"]) for r in cur.fetchall() if r.get("club_id") is not None}
_GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"}) _GOVERNANCE_VISIBILITY = frozenset({"private", "club", "official"})

View File

@ -10,7 +10,7 @@ from typing import Any, Optional
from fastapi import HTTPException from fastapi import HTTPException
from club_tenancy import can_manage_club_org, is_platform_admin from club_tenancy import can_manage_club_org, is_platform_admin, is_superadmin
from db import r2d from db import r2d
from media_storage import get_effective_media_root, path_under_media_root from media_storage import get_effective_media_root, path_under_media_root
@ -26,7 +26,8 @@ HIDDEN_TO_PURGE_DAYS = max(1, int(os.getenv("MEDIA_TRASH_HIDDEN_TO_PURGE_DAYS",
def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None: def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict) -> None:
""" """
Wer Medien in Papierkorb / Recovery / Purge versetzen darf (§5.2 Kurzfassung). Papierkorb Stufe 2 / Recovery / Reaktivierung nicht für trash_soft (siehe assert_can_trash_soft).
§5.2: official nur Plattform-Admin; club Vereinsorga; privat nur Uploader.
""" """
profile_id = tenant.profile_id profile_id = tenant.profile_id
role = (tenant.global_role or "").strip().lower() role = (tenant.global_role or "").strip().lower()
@ -50,6 +51,102 @@ def assert_can_manage_media_asset_lifecycle(cur: Any, tenant: Any, asset: dict)
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def assert_can_trash_soft(cur: Any, tenant: Any, asset: dict) -> None:
"""
Aktiv Papierkorb (Stufe 1). Trainer: nur eigene private Uploads.
Vereinsmedien: Vereinsorga; official: Plattform-Admin; Superadmin: immer.
"""
role_raw = tenant.global_role
role = (role_raw or "").strip().lower()
if is_superadmin(role):
return
if is_platform_admin(role):
return
vis = (asset.get("visibility") or "private").strip().lower()
uid = asset.get("uploaded_by_profile_id")
cid = asset.get("club_id")
pid = int(tenant.profile_id)
if vis == "private":
if uid is not None and int(uid) == pid:
return
raise HTTPException(
status_code=403,
detail="Nur eigene private Medien dürfen in den Papierkorb",
)
if vis == "club":
if cid is not None and can_manage_club_org(cur, pid, int(cid), role_raw):
return
raise HTTPException(
status_code=403,
detail="Nur Vereinsorganisation darf Vereinsmedien in den Papierkorb legen",
)
if vis == "official":
raise HTTPException(status_code=403, detail="Nur Plattform-Admin")
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def assert_can_edit_media_asset_metadata(cur: Any, tenant: Any, asset: dict) -> None:
"""PATCH Metadaten / Sichtbarkeit — gleiche Stufen wie Lifecycle (ohne Papierkorb)."""
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
def superadmin_force_lifecycle_state(cur: Any, conn: Any, asset_id: int, target: str) -> dict:
"""Nur Superadmin: Zustand direkt setzen."""
if target not in (LC_ACTIVE, LC_TRASH_SOFT, LC_TRASH_HIDDEN):
raise HTTPException(status_code=400, detail="Ungültiger Ziel-Lifecycle")
if target == LC_ACTIVE:
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
RETURNING id, lifecycle_state""",
(LC_ACTIVE, asset_id),
)
elif target == LC_TRASH_SOFT:
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_soft_at = COALESCE(trash_soft_at, NOW()),
trash_hidden_at = NULL, purge_after_at = NULL, updated_at = NOW()
WHERE id = %s
RETURNING id, lifecycle_state, trash_soft_at""",
(LC_TRASH_SOFT, asset_id),
)
else:
pa = datetime.now(timezone.utc) + timedelta(days=HIDDEN_TO_PURGE_DAYS)
cur.execute(
"""UPDATE media_assets
SET lifecycle_state = %s, trash_hidden_at = NOW(),
purge_after_at = %s, updated_at = NOW(),
trash_soft_at = COALESCE(trash_soft_at, NOW())
WHERE id = %s
RETURNING id, lifecycle_state, trash_hidden_at, purge_after_at""",
(LC_TRASH_HIDDEN, pa, asset_id),
)
row = cur.fetchone()
if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
conn.commit()
return r2d(row)
def superadmin_hard_delete_media_asset(cur: Any, conn: Any, asset_id: int) -> bool:
"""Nur Superadmin: Zeile + Datei unabhängig vom Lifecycle entfernen."""
cur.execute(
"SELECT id, storage_key FROM media_assets WHERE id = %s",
(asset_id,),
)
row = cur.fetchone()
if not row:
return False
asset = r2d(row)
cur.execute("DELETE FROM exercise_media WHERE media_asset_id = %s", (asset_id,))
purge_asset_filesystem(cur, asset)
cur.execute("DELETE FROM media_assets WHERE id = %s", (asset_id,))
conn.commit()
return True
def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]: def fetch_media_asset_row(cur: Any, asset_id: int) -> Optional[dict]:
cur.execute( cur.execute(
"""SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,

View File

@ -4,12 +4,32 @@ from __future__ import annotations
from typing import Any, Literal, Optional from typing import Any, Literal, Optional
from fastapi import APIRouter, Depends, HTTPException, Query, Request from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field, model_validator
from pydantic import BaseModel, Field from club_tenancy import (
assert_valid_governance_visibility,
from club_tenancy import is_platform_admin, library_content_visible_to_profile club_ids_for_profile_with_roles,
is_platform_admin,
is_superadmin,
library_content_visible_to_profile,
)
from db import get_db, get_cursor, r2d from db import get_db, get_cursor, r2d
from media_lifecycle import fetch_media_asset_row from media_lifecycle import (
LC_ACTIVE,
LC_TRASH_HIDDEN,
LC_TRASH_SOFT,
assert_can_edit_media_asset_metadata,
assert_can_manage_media_asset_lifecycle,
assert_can_trash_soft,
fetch_media_asset_row,
purge_media_asset,
reactivate_media_asset_from_trash,
superadmin_force_lifecycle_state,
superadmin_hard_delete_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden,
transition_to_trash_soft,
)
from media_storage import get_effective_media_root, path_under_media_root from media_storage import get_effective_media_root, path_under_media_root
from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible
@ -17,16 +37,79 @@ router = APIRouter(prefix="/api/media-assets", tags=["media-assets"])
class MediaLifecycleBody(BaseModel): class MediaLifecycleBody(BaseModel):
action: Literal["trash_soft", "trash_hidden", "recover", "purge", "reactivate"] action: Literal[
"trash_soft",
"trash_hidden",
"recover",
"purge",
"reactivate",
"superadmin_force_lifecycle",
"superadmin_hard_delete",
]
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
@model_validator(mode="after")
def _target_lifecycle_rules(self):
if self.action == "superadmin_force_lifecycle":
if not self.target_lifecycle:
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
elif self.target_lifecycle is not None:
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
return self
class MediaAssetPatch(BaseModel): class MediaAssetPatch(BaseModel):
copyright_notice: Optional[str] = Field(None, max_length=8000) copyright_notice: Optional[str] = Field(None, max_length=8000)
original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
class MediaBulkLifecycleBody(BaseModel):
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
action: Literal[
"trash_soft",
"trash_hidden",
"recover",
"purge",
"reactivate",
"superadmin_force_lifecycle",
"superadmin_hard_delete",
]
target_lifecycle: Optional[Literal["active", "trash_soft", "trash_hidden"]] = None
@model_validator(mode="after")
def _bulk_target(self):
if self.action == "superadmin_force_lifecycle" and not self.target_lifecycle:
raise ValueError("target_lifecycle ist für diese Aktion erforderlich")
if self.action != "superadmin_force_lifecycle" and self.target_lifecycle is not None:
raise ValueError("target_lifecycle nur bei superadmin_force_lifecycle")
return self
class MediaBulkPatchBody(BaseModel):
media_asset_ids: list[int] = Field(..., min_length=1, max_length=200)
copyright_notice: Optional[str] = Field(None, max_length=8000)
original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"}) _LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
"""Nach visibility-Wechsel club_id konsistent setzen (official/private → NULL)."""
eff = dict(patch_fields)
if eff.get("visibility") is not None:
v = str(eff["visibility"]).strip().lower()
if v in ("official", "private"):
eff["club_id"] = None
elif v == "club" and "club_id" not in eff:
eff["club_id"] = asset.get("club_id")
return eff
def _lifecycle_where_sql(lifecycle: str) -> str: def _lifecycle_where_sql(lifecycle: str) -> str:
lc = (lifecycle or "active").strip().lower() lc = (lifecycle or "active").strip().lower()
if lc not in _LIFECYCLE_LIST_FILTERS: if lc not in _LIFECYCLE_LIST_FILTERS:
@ -63,6 +146,116 @@ def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict)
raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium")
def _item_permissions(row: dict, tenant: TenantContext, admin_club_ids: set[int]) -> dict:
"""Berechnete UI-/Policy-Flags pro Zeile (ohne zusätzliche DB)."""
role_raw = tenant.global_role
role = (role_raw or "").strip().lower()
pid = int(tenant.profile_id)
sup = is_superadmin(role_raw)
plat = is_platform_admin(role_raw)
vis = (row.get("visibility") or "private").strip().lower()
uid = row.get("uploaded_by_profile_id")
cid = row.get("club_id")
lc = (row.get("lifecycle_state") or "active").strip().lower()
is_owner = uid is not None and int(uid) == pid
club_mgr = plat or (cid is not None and int(cid) in admin_club_ids)
edit_metadata = (
sup
or (vis == "official" and plat)
or (vis == "club" and club_mgr)
or (vis == "private" and is_owner)
)
trash_soft = lc == "active" and (
sup or plat or (vis == "private" and is_owner) or (vis == "club" and club_mgr)
)
if vis == "official" and not (sup or plat):
trash_soft = False
can_manage_adv = (
sup
or plat
or (vis == "private" and is_owner)
or (vis == "club" and club_mgr)
)
trash_hidden = lc in ("active", "trash_soft") and can_manage_adv
recover_from_hidden = lc == "trash_hidden" and can_manage_adv
reactivate = lc in ("trash_soft", "trash_hidden") and can_manage_adv
purge = lc == "trash_hidden" and sup
return {
"edit_metadata": edit_metadata,
"change_visibility": edit_metadata,
"trash_soft": trash_soft,
"trash_hidden": trash_hidden,
"recover": recover_from_hidden,
"reactivate": reactivate,
"purge": purge,
"superadmin_lifecycle": sup,
"superadmin_hard_delete": sup,
}
def _apply_lifecycle_action(
cur: Any,
conn: Any,
asset_id: int,
body: MediaLifecycleBody,
tenant: TenantContext,
) -> dict:
asset = fetch_media_asset_row(cur, asset_id)
if not asset:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
action = body.action
role_raw = tenant.global_role
if action == "superadmin_hard_delete":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Nur Superadmin")
ok = superadmin_hard_delete_media_asset(cur, conn, asset_id)
if not ok:
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
return {"ok": True, "hard_deleted": asset_id}
if action == "superadmin_force_lifecycle":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Nur Superadmin")
tl = body.target_lifecycle or "active"
mp = {"active": LC_ACTIVE, "trash_soft": LC_TRASH_SOFT, "trash_hidden": LC_TRASH_HIDDEN}
return superadmin_force_lifecycle_state(cur, conn, asset_id, mp[tl])
if action == "purge":
if not is_superadmin(role_raw):
raise HTTPException(status_code=403, detail="Endgültiges Löschen nur als Superadmin")
state = (asset.get("lifecycle_state") or "").strip().lower()
if state != LC_TRASH_HIDDEN:
raise HTTPException(
status_code=400,
detail="Nur ausgeblendete Medien (Stufe 2) dürfen mit dieser Aktion entfernt werden",
)
if not purge_media_asset(cur, conn, asset_id):
raise HTTPException(status_code=400, detail="Löschen nicht möglich")
return {"ok": True, "purged": asset_id}
if action == "trash_soft":
assert_can_trash_soft(cur, tenant, asset)
return transition_to_trash_soft(cur, conn, asset_id)
if action == "trash_hidden":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_to_trash_hidden(cur, conn, asset_id)
if action == "recover":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return transition_recover_from_hidden(cur, conn, asset_id)
if action == "reactivate":
assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
return reactivate_media_asset_from_trash(cur, conn, asset_id)
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action")
@router.get("") @router.get("")
def list_media_assets( def list_media_assets(
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
@ -74,10 +267,6 @@ def list_media_assets(
limit: int = Query(30, ge=1, le=100), limit: int = Query(30, ge=1, le=100),
offset: int = Query(0, ge=0), offset: int = Query(0, ge=0),
): ):
"""
Durchsuchbares Medien-Archiv; Sichtbarkeit wie Übungsbibliothek.
Standard lifecycle=active (Archiv-Picker); Manager-UI kann Papierkorb-Ansicht wählen.
"""
lc_where = _lifecycle_where_sql(lifecycle) lc_where = _lifecycle_where_sql(lifecycle)
role = tenant.global_role or "" role = tenant.global_role or ""
is_adm = is_platform_admin(role) is_adm = is_platform_admin(role)
@ -93,11 +282,17 @@ def list_media_assets(
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
cur.execute( cur.execute(
f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id, f"""SELECT ma.id, ma.mime_type, ma.byte_size, ma.original_filename, ma.visibility, ma.club_id,
ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256, ma.uploaded_by_profile_id, ma.lifecycle_state, ma.created_at, ma.sha256,
ma.copyright_notice ma.copyright_notice, ma.storage_key,
pr.name AS uploader_name,
pr.email AS uploader_email,
cl.name AS club_name
FROM media_assets ma FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
LEFT JOIN clubs cl ON cl.id = ma.club_id
WHERE {lc_where} WHERE {lc_where}
AND ( AND (
%s %s
@ -122,7 +317,29 @@ def list_media_assets(
params, params,
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
return {"items": rows, "limit": limit, "offset": offset, "lifecycle": lifecycle.strip().lower()} show_uploader = is_superadmin(role) or is_platform_admin(role) or bool(admin_club_ids)
show_club = is_superadmin(role) or is_platform_admin(role)
for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
if not show_uploader:
r["uploader_name"] = None
r["uploader_email"] = None
if not show_club:
r["club_name"] = None
viewer = {
"show_uploader_meta": show_uploader,
"show_club_meta": show_club,
"is_superadmin": is_superadmin(role),
"is_platform_admin": is_platform_admin(role),
}
return {
"items": rows,
"limit": limit,
"offset": offset,
"lifecycle": lifecycle.strip().lower(),
"viewer": viewer,
}
@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"]) @router.api_route("/{asset_id}/file", methods=["GET", "HEAD"])
@ -143,8 +360,6 @@ def download_media_asset_file(
if lc == "active": if lc == "active":
_assert_can_view_archive_asset(cur, tenant, asset) _assert_can_view_archive_asset(cur, tenant, asset)
elif lc in ("trash_soft", "trash_hidden"): elif lc in ("trash_soft", "trash_hidden"):
from media_lifecycle import assert_can_manage_media_asset_lifecycle
assert_can_manage_media_asset_lifecycle(cur, tenant, asset) assert_can_manage_media_asset_lifecycle(cur, tenant, asset)
else: else:
raise HTTPException(status_code=404, detail="Medium nicht verfügbar") raise HTTPException(status_code=404, detail="Medium nicht verfügbar")
@ -169,46 +384,103 @@ def post_media_asset_lifecycle(
body: MediaLifecycleBody, body: MediaLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Papierkorb-Übergänge — media_lifecycle.""" with get_db() as conn:
from media_lifecycle import ( cur = get_cursor(conn)
assert_can_manage_media_asset_lifecycle, return _apply_lifecycle_action(cur, conn, asset_id, body, tenant)
purge_media_asset,
transition_recover_from_hidden,
transition_to_trash_hidden, @router.post("/bulk-lifecycle")
transition_to_trash_soft, def bulk_media_lifecycle(
) body: MediaBulkLifecycleBody,
tenant: TenantContext = Depends(get_tenant_context),
):
inner = MediaLifecycleBody(action=body.action, target_lifecycle=body.target_lifecycle)
updated: list[int] = []
failed: list[dict] = []
with get_db() as conn:
cur = get_cursor(conn)
for aid in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
try:
_apply_lifecycle_action(cur, conn, aid, inner, tenant)
updated.append(aid)
except HTTPException as he:
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
failed.append({"id": aid, "detail": msg})
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
@router.post("/bulk-patch")
def bulk_media_patch(
body: MediaBulkPatchBody,
tenant: TenantContext = Depends(get_tenant_context),
):
raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
patch_fields = {k: v for k, v in raw.items() if k != "media_asset_ids" and v is not None}
if not patch_fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
updated: list[int] = []
failed: list[dict] = []
profile_id = tenant.profile_id
role = tenant.global_role
with get_db() as conn: with get_db() as conn:
cur = get_cursor(conn) cur = get_cursor(conn)
asset = fetch_media_asset_row(cur, asset_id) for asset_id in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)):
if not asset: try:
raise HTTPException(status_code=404, detail="Medium nicht gefunden") cur.execute(
assert_can_manage_media_asset_lifecycle(cur, tenant, asset) """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state,
copyright_notice, original_filename
action = body.action FROM media_assets WHERE id = %s""",
if action == "trash_soft": (asset_id,),
return transition_to_trash_soft(cur, conn, asset_id)
if action == "trash_hidden":
return transition_to_trash_hidden(cur, conn, asset_id)
if action == "recover":
return transition_recover_from_hidden(cur, conn, asset_id)
if action == "purge":
state = (asset.get("lifecycle_state") or "").strip().lower()
if state != "trash_hidden":
raise HTTPException(
status_code=400,
detail="Nur ausgeblendete Medien (Stufe 2) dürfen endgültig gelöscht werden",
) )
ok = purge_media_asset(cur, conn, asset_id) row = cur.fetchone()
if not ok: if not row:
raise HTTPException(status_code=400, detail="Löschen nicht möglich") failed.append({"id": asset_id, "detail": "Medium nicht gefunden"})
return {"ok": True, "purged": asset_id} continue
if action == "reactivate": asset = r2d(row)
from media_lifecycle import reactivate_media_asset_from_trash assert_can_edit_media_asset_metadata(cur, tenant, asset)
return reactivate_media_asset_from_trash(cur, conn, asset_id) eff = _effective_media_patch_fields(patch_fields, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
if "visibility" in patch_fields or "club_id" in patch_fields:
assert_valid_governance_visibility(
cur,
profile_id,
role,
next_vis,
int(next_cid) if next_cid is not None else None,
)
raise HTTPException(status_code=500, detail="Interner Fehler: lifecycle action") sets: list[str] = []
vals: list[Any] = []
if "copyright_notice" in patch_fields:
sets.append("copyright_notice = %s")
vals.append(patch_fields["copyright_notice"])
if "original_filename" in patch_fields:
sets.append("original_filename = %s")
vals.append(patch_fields["original_filename"])
if "visibility" in patch_fields or "club_id" in patch_fields:
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if not sets:
failed.append({"id": asset_id, "detail": "Nichts zu aktualisieren"})
continue
sets.append("updated_at = NOW()")
vals.append(asset_id)
cur.execute(
f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
conn.commit()
updated.append(asset_id)
except HTTPException as he:
msg = he.detail if isinstance(he.detail, str) else str(he.detail)
failed.append({"id": asset_id, "detail": msg})
return {"updated": updated, "failed": failed, "updated_count": len(updated), "failed_count": len(failed)}
@router.patch("/{asset_id}") @router.patch("/{asset_id}")
@ -217,10 +489,9 @@ def patch_media_asset(
body: MediaAssetPatch, body: MediaAssetPatch,
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
"""Metadaten (z. B. Copyright) — gleiche Berechtigung wie Lifecycle-Verwaltung.""" profile_id = tenant.profile_id
from media_lifecycle import assert_can_manage_media_asset_lifecycle role = tenant.global_role
data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True)
data = body.dict(exclude_unset=True)
if not data: if not data:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
@ -236,13 +507,39 @@ def patch_media_asset(
if not row: if not row:
raise HTTPException(status_code=404, detail="Medium nicht gefunden") raise HTTPException(status_code=404, detail="Medium nicht gefunden")
asset = r2d(row) asset = r2d(row)
assert_can_manage_media_asset_lifecycle(cur, tenant, asset) assert_can_edit_media_asset_metadata(cur, tenant, asset)
eff = _effective_media_patch_fields(data, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower()
next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
if "visibility" in data or "club_id" in data:
assert_valid_governance_visibility(
cur,
profile_id,
role,
next_vis,
int(next_cid) if next_cid is not None else None,
)
sets: list[str] = []
vals: list[Any] = []
if "copyright_notice" in data: if "copyright_notice" in data:
cn = data["copyright_notice"] sets.append("copyright_notice = %s")
vals.append(data["copyright_notice"])
if "original_filename" in data:
sets.append("original_filename = %s")
vals.append(data["original_filename"])
if "visibility" in data or "club_id" in data:
sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip())
sets.append("club_id = %s")
vals.append(eff.get("club_id"))
if sets:
sets.append("updated_at = NOW()")
vals.append(asset_id)
cur.execute( cur.execute(
"UPDATE media_assets SET copyright_notice = %s, updated_at = NOW() WHERE id = %s", f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s",
(cn, asset_id), tuple(vals),
) )
conn.commit() conn.commit()
cur.execute( cur.execute(

View File

@ -66,7 +66,7 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
with patch("routers.media_assets.get_db", return_value=mock_cm), patch( with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur "routers.media_assets.get_cursor", return_value=mock_cur
): ), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"}) r = client.get("/api/media-assets?q=test", headers={"X-Auth-Token": "t"})
assert r.status_code == 200 assert r.status_code == 200
@ -74,6 +74,7 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
assert body["limit"] == 30 assert body["limit"] == 30
assert len(body["items"]) == 1 assert len(body["items"]) == 1
assert body["items"][0]["original_filename"] == "a.png" assert body["items"][0]["original_filename"] == "a.png"
assert "viewer" in body
def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None: def test_attach_from_asset_duplicate_returns_400(client: TestClient) -> None:
@ -315,6 +316,45 @@ def test_media_asset_lifecycle_reactivate_mocked(client: TestClient) -> None:
assert r.json()["lifecycle_state"] == "active" assert r.json()["lifecycle_state"] == "active"
def test_media_asset_lifecycle_purge_forbidden_for_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 1, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=1,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
mock_cur = MagicMock()
mock_cm = _mock_db(mock_cur)
fake_row = {
"id": 99,
"visibility": "official",
"club_id": None,
"uploaded_by_profile_id": 2,
"lifecycle_state": "trash_hidden",
"storage_key": "x",
"storage_backend": "local",
"trash_soft_at": None,
"trash_hidden_at": None,
"purge_after_at": None,
}
with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur
), patch("routers.media_assets.fetch_media_asset_row", return_value=fake_row):
r = client.post(
"/api/media-assets/99/lifecycle",
headers={"X-Auth-Token": "t", "Content-Type": "application/json"},
json={"action": "purge"},
)
assert r.status_code == 403
assert "Superadmin" in (r.json().get("detail") or "")
def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None: def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"} app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext( app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
@ -329,12 +369,12 @@ def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> No
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)
with patch("routers.media_assets.get_db", return_value=mock_cm), patch( with patch("routers.media_assets.get_db", return_value=mock_cm), patch(
"routers.media_assets.get_cursor", return_value=mock_cur "routers.media_assets.get_cursor", return_value=mock_cur
): ), patch("routers.media_assets.club_ids_for_profile_with_roles", return_value=set()):
r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"}) r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"})
assert r.status_code == 200 assert r.status_code == 200
assert r.json()["lifecycle"] == "trash_soft" assert r.json()["lifecycle"] == "trash_soft"
first_sql = mock_cur.execute.call_args_list[0][0][0] list_sql_calls = [c[0][0] for c in mock_cur.execute.call_args_list if c[0] and "FROM media_assets ma" in str(c[0][0])]
assert "trash_soft" in first_sql assert list_sql_calls and "trash_soft" in list_sql_calls[0]
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None: def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None:

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.48" APP_VERSION = "0.8.49"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045" DB_SCHEMA_VERSION = "20260507045"
@ -13,7 +13,7 @@ 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.3.0", # Liste lifecycle-Filter; PATCH Metadaten; GET /file für Papierkorb bei Verwaltungsrecht "media_assets": "1.4.0", # Manager: RBAC trash_soft Trainer nur privat; purge nur Superadmin; Superadmin force/hard-delete; Liste + permissions + JOINs; bulk lifecycle/patch
"groups": "0.1.0", "groups": "0.1.0",
"skills": "0.1.0", "skills": "0.1.0",
"methods": "0.1.0", "methods": "0.1.0",
@ -29,6 +29,13 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.49",
"date": "2026-05-07",
"changes": [
"Medienbibliothek UI: Kacheln/Liste, Modal Bearbeiten, Video-First-Frame-Thumbs, Mobile/Safe-Area, Bulk; API: permissions pro Zeile, Uploader/Verein für Admin, PATCH Sichtbarkeit+Bezeichner, trash_soft nur Trainer-Eigenes-Privat / Vereinsorga / Plattform; purge nur Superadmin; superadmin_force_lifecycle + hard_delete; bulk-lifecycle, bulk-patch",
],
},
{ {
"version": "0.8.48", "version": "0.8.48",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -5454,3 +5454,414 @@ a.analysis-split__nav-item {
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-bottom: 4px; padding-bottom: 4px;
} }
/* ——— Medienbibliothek (/media) ——— */
.media-library {
padding: max(12px, env(safe-area-inset-top, 0px)) max(16px, env(safe-area-inset-right, 0px))
max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px));
}
.media-library__container {
max-width: 1200px;
margin: 0 auto;
}
.media-library__hero {
margin-bottom: 1.25rem;
}
.media-library__hero-row {
display: flex;
flex-wrap: wrap;
align-items: baseline;
justify-content: space-between;
gap: 0.75rem;
}
.media-library__title {
font-size: clamp(1.35rem, 2.5vw, 1.65rem);
font-weight: 700;
letter-spacing: -0.02em;
margin: 0;
}
.media-library__hero-links {
display: flex;
gap: 1rem;
font-size: 0.9rem;
}
.media-library__hero-links a {
color: var(--accent-dark);
}
.media-library__intro {
margin: 0.5rem 0 0;
font-size: 0.9rem;
color: var(--text2);
line-height: 1.5;
max-width: 46rem;
}
.media-library__toolbar {
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
padding: 12px 14px;
margin-bottom: 1rem;
display: flex;
flex-direction: column;
gap: 10px;
}
.media-library__toolbar-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
}
.media-library__search {
flex: 1 1 200px;
min-width: 0;
}
.media-library__select {
min-width: 160px;
}
.media-library__view-toggle {
display: inline-flex;
border: 1px solid var(--border);
border-radius: 10px;
overflow: hidden;
}
.media-library__toolbar-meta {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 12px;
font-size: 0.875rem;
}
.media-library__check-all {
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
}
.media-library__refresh {
margin-left: auto;
}
.media-library__icon-btn {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 44px;
padding: 0 10px;
border: 1px solid var(--border);
border-radius: 10px;
background: var(--surface);
color: var(--text1);
cursor: pointer;
}
.media-library__icon-btn--on {
background: var(--accent-light);
border-color: var(--accent);
color: var(--accent-dark);
}
.media-library__err {
color: var(--danger);
margin-bottom: 1rem;
}
.media-library__empty,
.media-library__hint {
color: var(--text2);
font-size: 0.9rem;
}
.media-library__spinner {
margin: 2rem auto;
}
.media-library__grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(158px, 1fr));
gap: 14px;
}
@media (min-width: 768px) {
.media-library__grid {
grid-template-columns: repeat(auto-fill, minmax(176px, 1fr));
gap: 16px;
}
}
.media-library__card {
position: relative;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 14px;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.media-library__card-check {
position: absolute;
top: 8px;
left: 8px;
z-index: 3;
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
background: rgba(255, 255, 255, 0.92);
border-radius: 8px;
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.08);
}
.media-library__card-check input {
width: 18px;
height: 18px;
cursor: pointer;
}
.media-library__card-menu {
position: absolute;
top: 8px;
right: 8px;
z-index: 3;
width: 44px;
height: 44px;
display: flex;
align-items: center;
justify-content: center;
border: none;
border-radius: 10px;
background: rgba(255, 255, 255, 0.92);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
cursor: pointer;
color: var(--text1);
}
.media-library__card-thumb-wrap {
aspect-ratio: 1;
background: var(--surface2);
position: relative;
}
.media-library__thumb-img,
.media-library__thumb-video {
width: 100%;
height: 100%;
object-fit: cover;
display: block;
}
.media-library__thumb-ph {
width: 100%;
height: 100%;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.75rem;
color: var(--text3);
font-weight: 600;
}
.media-library__card-footer {
padding: 10px 10px 12px;
min-height: 0;
}
.media-library__card-name {
font-size: 0.8125rem;
font-weight: 600;
line-height: 1.35;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-library__card-tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 6px;
font-size: 0.7rem;
color: var(--text2);
}
.media-library__card-tags span {
background: var(--surface2);
padding: 2px 6px;
border-radius: 6px;
}
.media-library__table-wrap {
overflow-x: auto;
-webkit-overflow-scrolling: touch;
border: 1px solid var(--border);
border-radius: 12px;
background: var(--surface);
}
.media-library__table {
width: 100%;
border-collapse: collapse;
font-size: 0.875rem;
}
.media-library__table th,
.media-library__table td {
padding: 10px 12px;
text-align: left;
border-bottom: 1px solid var(--border);
vertical-align: middle;
}
.media-library__table th {
font-weight: 600;
color: var(--text2);
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.media-library__th-check {
width: 40px;
}
.media-library__th-act {
width: 52px;
}
.media-library__td-thumb {
width: 72px;
}
.media-library__table-thumb {
width: 56px;
height: 56px;
border-radius: 8px;
overflow: hidden;
background: var(--surface2);
}
.media-library__table-thumb .media-library__thumb-img,
.media-library__table-thumb .media-library__thumb-video {
width: 56px;
height: 56px;
}
.media-library__td-name {
font-weight: 500;
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
}
.media-library__td-sub {
font-size: 0.8rem;
color: var(--text2);
max-width: 160px;
}
@media (max-width: 639px) {
.media-library__table .media-library__td-sub,
.media-library__table th:nth-child(5),
.media-library__table td:nth-child(5) {
display: none;
}
}
.media-library__overlay {
position: fixed;
inset: 0;
z-index: 200;
background: rgba(0, 0, 0, 0.45);
display: flex;
align-items: flex-end;
justify-content: center;
padding: env(safe-area-inset-top, 0px) 0 0;
}
@media (min-width: 640px) {
.media-library__overlay {
align-items: center;
padding: 24px 16px;
}
}
.media-library__modal {
width: 100%;
max-width: 480px;
max-height: min(92vh, 720px);
overflow-y: auto;
background: var(--surface);
border-radius: 16px 16px 0 0;
box-shadow: 0 -8px 40px rgba(0, 0, 0, 0.2);
}
@media (min-width: 640px) {
.media-library__modal {
border-radius: 16px;
max-height: 90vh;
}
}
.media-library__modal--wide {
max-width: 520px;
}
.media-library__modal-head {
display: flex;
align-items: center;
justify-content: space-between;
padding: 16px 18px;
border-bottom: 1px solid var(--border);
position: sticky;
top: 0;
background: var(--surface);
z-index: 1;
}
.media-library__modal-head h2 {
margin: 0;
font-size: 1.1rem;
}
.media-library__modal-body {
padding: 16px 18px 24px;
display: flex;
flex-direction: column;
gap: 12px;
}
.media-library__check {
display: flex;
align-items: center;
gap: 10px;
font-size: 0.9rem;
cursor: pointer;
}
.media-library__meta-block {
display: grid;
gap: 10px;
padding: 12px;
background: var(--surface2);
border-radius: 10px;
font-size: 0.85rem;
}
.media-library__meta-k {
display: block;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text3);
margin-bottom: 2px;
}
.media-library__meta-v {
color: var(--text1);
word-break: break-word;
}
.media-library__meta-v.mono {
font-family: ui-monospace, monospace;
font-size: 0.78rem;
}
.media-library__modal-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-top: 8px;
}
.media-library__lc-block {
margin-top: 8px;
padding-top: 16px;
border-top: 1px solid var(--border);
}
.media-library__lc-block--danger {
background: rgba(216, 90, 48, 0.06);
margin: 8px -18px -24px;
padding: 16px 18px 20px;
border-radius: 0 0 16px 16px;
}
.media-library__lc-title {
font-size: 0.8rem;
font-weight: 600;
color: var(--text2);
margin-bottom: 10px;
}
.media-library__lc-btns {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.media-library__row {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-bottom: 10px;
}
.media-library__row .form-input {
flex: 1 1 160px;
min-width: 0;
}

File diff suppressed because it is too large Load Diff

View File

@ -521,10 +521,10 @@ export async function reorderExerciseMedia(exerciseId, mediaIds) {
} }
/** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */ /** Papierkorb / Recovery — `action`: trash_soft | trash_hidden | recover | purge | reactivate */
export async function postMediaAssetLifecycle(assetId, action) { export async function postMediaAssetLifecycle(assetId, action, extra = {}) {
return request(`/api/media-assets/${assetId}/lifecycle`, { return request(`/api/media-assets/${assetId}/lifecycle`, {
method: 'POST', method: 'POST',
body: JSON.stringify({ action }), body: JSON.stringify({ action, ...extra }),
}) })
} }
@ -546,6 +546,20 @@ export async function patchMediaAsset(assetId, data) {
}) })
} }
export async function bulkMediaLifecycle(data) {
return request('/api/media-assets/bulk-lifecycle', {
method: 'POST',
body: JSON.stringify(data),
})
}
export async function bulkPatchMediaAssets(data) {
return request('/api/media-assets/bulk-patch', {
method: 'POST',
body: JSON.stringify(data),
})
}
/** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */ /** Übung: bestehendes Archiv-Medium verknüpfen (`POST /api/exercises/{id}/media/from-asset`). */
export async function attachExerciseMediaFromAsset(exerciseId, body) { export async function attachExerciseMediaFromAsset(exerciseId, body) {
return request(`/api/exercises/${exerciseId}/media/from-asset`, { return request(`/api/exercises/${exerciseId}/media/from-asset`, {
@ -1293,6 +1307,8 @@ export const api = {
postMediaAssetLifecycle, postMediaAssetLifecycle,
listMediaAssets, listMediaAssets,
patchMediaAsset, patchMediaAsset,
bulkMediaLifecycle,
bulkPatchMediaAssets,
attachExerciseMediaFromAsset, attachExerciseMediaFromAsset,
listExerciseProgressionGraphs, listExerciseProgressionGraphs,
getExerciseProgressionGraph, getExerciseProgressionGraph,