From b8453f3f07c9d88e740498e92b0b88e1470c339c Mon Sep 17 00:00:00 2001 From: Lars Date: Thu, 7 May 2026 14:33:02 +0200 Subject: [PATCH] feat: enhance media asset lifecycle management and permissions - Introduced new API endpoints for bulk lifecycle actions and bulk patching of media assets, allowing for more efficient management of multiple assets. - Updated media lifecycle permissions to restrict actions based on user roles, ensuring that only superadmins can perform critical operations like purging and force lifecycle changes. - Enhanced frontend components to support new bulk actions and improved user experience in the media library, including visibility and copyright management. - Incremented version to 0.8.49, reflecting the latest improvements in media handling and governance. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 6 +- backend/club_tenancy.py | 22 + backend/media_lifecycle.py | 101 +- backend/routers/media_assets.py | 407 +++++++- backend/tests/test_media_assets_archive.py | 48 +- backend/version.py | 11 +- frontend/src/app.css | 411 ++++++++ frontend/src/pages/MediaLibraryPage.jsx | 982 ++++++++++++------ frontend/src/utils/api.js | 20 +- 9 files changed, 1650 insertions(+), 358 deletions(-) diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index 0f512e1..b0a613c 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -18,9 +18,11 @@ 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 | `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 | `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 | | 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 | diff --git a/backend/club_tenancy.py b/backend/club_tenancy.py index 4da0747..10a6135 100644 --- a/backend/club_tenancy.py +++ b/backend/club_tenancy.py @@ -11,6 +11,10 @@ def is_platform_admin(role: Optional[str]) -> bool: 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]: cur.execute( """ @@ -125,6 +129,24 @@ def memberships_with_roles(cur, profile_id: int, active_only: bool = True) -> Li 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"}) diff --git a/backend/media_lifecycle.py b/backend/media_lifecycle.py index 30ad28e..570b4f4 100644 --- a/backend/media_lifecycle.py +++ b/backend/media_lifecycle.py @@ -10,7 +10,7 @@ from typing import Any, Optional 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 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: """ - 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 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") +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]: cur.execute( """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index edadb18..e11cd09 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -4,12 +4,32 @@ from __future__ import annotations from typing import Any, Literal, Optional from fastapi import APIRouter, Depends, HTTPException, Query, Request +from pydantic import BaseModel, Field, model_validator -from pydantic import BaseModel, Field - -from club_tenancy import is_platform_admin, library_content_visible_to_profile +from club_tenancy import ( + assert_valid_governance_visibility, + 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 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 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): - 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): 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"}) +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: lc = (lifecycle or "active").strip().lower() 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") +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("") def list_media_assets( tenant: TenantContext = Depends(get_tenant_context), @@ -74,10 +267,6 @@ def list_media_assets( limit: int = Query(30, ge=1, le=100), 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) role = tenant.global_role or "" is_adm = is_platform_admin(role) @@ -93,11 +282,17 @@ def list_media_assets( with get_db() as conn: cur = get_cursor(conn) + admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin") cur.execute( 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.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 + 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} AND ( %s @@ -122,7 +317,29 @@ def list_media_assets( params, ) 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"]) @@ -143,8 +360,6 @@ def download_media_asset_file( if lc == "active": _assert_can_view_archive_asset(cur, tenant, asset) 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) else: raise HTTPException(status_code=404, detail="Medium nicht verfügbar") @@ -169,46 +384,103 @@ def post_media_asset_lifecycle( body: MediaLifecycleBody, tenant: TenantContext = Depends(get_tenant_context), ): - """Papierkorb-Übergänge — media_lifecycle.""" - from media_lifecycle import ( - assert_can_manage_media_asset_lifecycle, - purge_media_asset, - transition_recover_from_hidden, - transition_to_trash_hidden, - transition_to_trash_soft, - ) + with get_db() as conn: + cur = get_cursor(conn) + return _apply_lifecycle_action(cur, conn, asset_id, body, tenant) + + +@router.post("/bulk-lifecycle") +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: cur = get_cursor(conn) - asset = fetch_media_asset_row(cur, asset_id) - if not asset: - raise HTTPException(status_code=404, detail="Medium nicht gefunden") - assert_can_manage_media_asset_lifecycle(cur, tenant, asset) - - action = body.action - if action == "trash_soft": - 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", + for asset_id in sorted(set(int(x) for x in body.media_asset_ids if x and int(x) > 0)): + try: + cur.execute( + """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, + copyright_notice, original_filename + FROM media_assets WHERE id = %s""", + (asset_id,), ) - ok = purge_media_asset(cur, conn, asset_id) - 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 + row = cur.fetchone() + if not row: + failed.append({"id": asset_id, "detail": "Medium nicht gefunden"}) + continue + asset = r2d(row) + 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}") @@ -217,10 +489,9 @@ def patch_media_asset( body: MediaAssetPatch, tenant: TenantContext = Depends(get_tenant_context), ): - """Metadaten (z. B. Copyright) — gleiche Berechtigung wie Lifecycle-Verwaltung.""" - from media_lifecycle import assert_can_manage_media_asset_lifecycle - - data = body.dict(exclude_unset=True) + profile_id = tenant.profile_id + role = tenant.global_role + data = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True) if not data: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") @@ -236,13 +507,39 @@ def patch_media_asset( if not row: raise HTTPException(status_code=404, detail="Medium nicht gefunden") 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: - 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( - "UPDATE media_assets SET copyright_notice = %s, updated_at = NOW() WHERE id = %s", - (cn, asset_id), + f"UPDATE media_assets SET {', '.join(sets)} WHERE id = %s", + tuple(vals), ) conn.commit() cur.execute( diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 01e976f..1b2c03b 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -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( "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"}) assert r.status_code == 200 @@ -74,6 +74,7 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None: assert body["limit"] == 30 assert len(body["items"]) == 1 assert body["items"][0]["original_filename"] == "a.png" + assert "viewer" in body 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" +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: app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"} 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) 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.club_ids_for_profile_with_roles", return_value=set()): r = client.get("/api/media-assets?lifecycle=trash_soft", headers={"X-Auth-Token": "t"}) assert r.status_code == 200 assert r.json()["lifecycle"] == "trash_soft" - first_sql = mock_cur.execute.call_args_list[0][0][0] - assert "trash_soft" in first_sql + 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 list_sql_calls and "trash_soft" in list_sql_calls[0] def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None: diff --git a/backend/version.py b/backend/version.py index 4c5cc83..a63a727 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.48" +APP_VERSION = "0.8.49" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260507045" @@ -13,7 +13,7 @@ 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.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", "skills": "0.1.0", "methods": "0.1.0", @@ -29,6 +29,13 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-07", diff --git a/frontend/src/app.css b/frontend/src/app.css index 2969738..794423a 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -5454,3 +5454,414 @@ a.analysis-split__nav-item { -webkit-overflow-scrolling: touch; 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; +} diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 4449efc..b3f9067 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -1,5 +1,11 @@ -import { useEffect, useState } from 'react' +import { useEffect, useState, useCallback } from 'react' import { Link } from 'react-router-dom' +import { + LayoutGrid, + List, + MoreVertical, + X, +} from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' import AdminPageNav from '../components/AdminPageNav' @@ -7,9 +13,15 @@ import { resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' const LC_OPTIONS = [ { value: 'active', label: 'Aktiv' }, - { value: 'trash_soft', label: 'Papierkorb (Stufe 1)' }, - { value: 'trash_hidden', label: 'Ausgeblendet (Stufe 2)' }, - { value: 'all', label: 'Alle (nicht purgiert)' }, + { value: 'trash_soft', label: 'Papierkorb (1)' }, + { value: 'trash_hidden', label: 'Ausgeblendet (2)' }, + { value: 'all', label: 'Alle' }, +] + +const VIS_OPTIONS = [ + { value: 'private', label: 'Privat' }, + { value: 'club', label: 'Verein' }, + { value: 'official', label: 'Offiziell' }, ] function lcLabel(code) { @@ -17,50 +29,94 @@ function lcLabel(code) { return o ? o.label : code } +function uploaderLabel(it, viewer) { + if (!viewer?.show_uploader_meta) return null + const n = (it.uploader_name || '').trim() + const e = (it.uploader_email || '').trim() + if (n) return n + if (e) return e + return it.uploaded_by_profile_id != null ? `Profil #${it.uploaded_by_profile_id}` : '—' +} + +function MediaThumb({ mediaId, mimeType }) { + const url = resolveMediaAssetFileUrl(mediaId) + const mime = (mimeType || '').toLowerCase() + if (mime.startsWith('video/')) { + return ( +