Merge pull request 'Medienmanager update 1' (#22) from develop into main
All checks were successful
Deploy Production / deploy (push) Successful in 36s
Test Suite / pytest-backend (push) Successful in 24s
Test Suite / lint-backend (push) Successful in 0s
Test Suite / build-frontend (push) Successful in 7s
Test Suite / playwright-tests (push) Successful in 23s

Reviewed-on: #22
This commit is contained in:
Lars 2026-05-07 16:30:20 +02:00
commit 696cb09bf4
9 changed files with 742 additions and 60 deletions

View File

@ -33,6 +33,7 @@ def _health_ready_public_detail_enabled() -> bool:
# Run database migrations before API start — halbes Schema ist schlimmer als kein Start # Run database migrations before API start — halbes Schema ist schlimmer als kein Start
# (run_migrations: pending *.sql in einer Transaktion pro Datei, Buchführung schema_migrations)
# Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1
if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"):
print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)") print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)")

View File

@ -0,0 +1,9 @@
-- Migration 046: Schlagwörter (tags) für media_assets — Suche & Filter in der Medienbibliothek.
--
-- Einordnung: läuft nach 045 (media_assets existiert). Idempotent:
-- • wird pro Umgebung höchstens einmal über schema_migrations ausgeführt;
-- • dieselben Statements sind bei Wiederholung harmlos (IF NOT EXISTS).
ALTER TABLE media_assets
ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
CREATE INDEX IF NOT EXISTS idx_media_assets_tags_gin ON media_assets USING GIN (tags);

View File

@ -1,7 +1,7 @@
"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC.""" """Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC."""
from __future__ import annotations from __future__ import annotations
from typing import Any, Literal, Optional from typing import Any, Literal, Optional, Union
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, model_validator
@ -63,6 +63,7 @@ class MediaAssetPatch(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300) original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
tags: Optional[list[str]] = None
class MediaBulkLifecycleBody(BaseModel): class MediaBulkLifecycleBody(BaseModel):
@ -93,9 +94,197 @@ class MediaBulkPatchBody(BaseModel):
original_filename: Optional[str] = Field(None, max_length=300) original_filename: Optional[str] = Field(None, max_length=300)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
club_id: Optional[int] = None club_id: Optional[int] = None
tags: Optional[list[str]] = None
_LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"}) _LIFECYCLE_LIST_FILTERS = frozenset({"active", "trash_soft", "trash_hidden", "all"})
_MEDIA_KIND_FILTERS = frozenset({"all", "image", "video", "pdf", "other"})
_MAX_TAGS = 40
_MAX_TAG_LEN = 48
_MEDIA_ASSETS_TAGS_COLUMN: Optional[bool] = None
_TRAINING_UNIT_EXERCISES_TABLE: Optional[bool] = None
def _media_assets_tags_column_present(cur: Any) -> bool:
"""True nach Migration 046; verhindert 500 wenn Code neuer ist als das Schema."""
global _MEDIA_ASSETS_TAGS_COLUMN
if _MEDIA_ASSETS_TAGS_COLUMN is not None:
return _MEDIA_ASSETS_TAGS_COLUMN
cur.execute(
"""
SELECT 1
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'media_assets'
AND column_name = 'tags'
LIMIT 1
"""
)
_MEDIA_ASSETS_TAGS_COLUMN = cur.fetchone() is not None
return _MEDIA_ASSETS_TAGS_COLUMN
def _training_unit_exercises_table_present(cur: Any) -> bool:
"""True wenn planning-Migration (training_unit_exercises) im Schema liegt — sonst keine Einheiten-Links."""
global _TRAINING_UNIT_EXERCISES_TABLE
if _TRAINING_UNIT_EXERCISES_TABLE is not None:
return _TRAINING_UNIT_EXERCISES_TABLE
cur.execute(
"""
SELECT 1
FROM information_schema.tables
WHERE table_schema = 'public'
AND table_name = 'training_unit_exercises'
LIMIT 1
"""
)
_TRAINING_UNIT_EXERCISES_TABLE = cur.fetchone() is not None
return _TRAINING_UNIT_EXERCISES_TABLE
def _normalize_media_tags(raw: Union[list[str], None]) -> list[str]:
"""PostgreSQL text[]; Einträge gekürzt und ohne Dubletten (case-insensitive)."""
if raw is None:
return []
if not isinstance(raw, list):
raise HTTPException(status_code=400, detail="tags muss eine Liste von Strings sein")
out: list[str] = []
seen: set[str] = set()
for item in raw:
s = str(item).strip()
if not s:
continue
s = s[:_MAX_TAG_LEN]
key = s.lower()
if key in seen:
continue
seen.add(key)
out.append(s)
if len(out) >= _MAX_TAGS:
break
return out
def _usage_for_media_assets(cur: Any, asset_ids: list[int]) -> dict[int, dict[str, Any]]:
"""Übungen und Trainingseinheiten, die dieses Archiv-Medium nutzen."""
if not asset_ids:
return {}
cur.execute(
"""SELECT em.media_asset_id, e.id AS exercise_id, e.title AS exercise_title
FROM exercise_media em
JOIN exercises e ON e.id = em.exercise_id
WHERE em.media_asset_id = ANY(%s)
ORDER BY em.media_asset_id, e.title NULLS LAST, e.id""",
(asset_ids,),
)
ex_by_asset: dict[int, list[dict]] = {int(aid): [] for aid in asset_ids}
seen_ex: dict[int, set[int]] = {int(aid): set() for aid in asset_ids}
exercise_ids: set[int] = set()
for r in cur.fetchall():
d = r2d(r)
aid = int(d["media_asset_id"])
eid = int(d["exercise_id"])
if aid not in seen_ex or eid in seen_ex[aid]:
continue
seen_ex[aid].add(eid)
exercise_ids.add(eid)
title = (d.get("exercise_title") or "").strip() or f"Übung #{eid}"
ex_by_asset[aid].append({"id": eid, "title": title})
exercise_to_units: dict[int, list[dict]] = {}
if exercise_ids and _training_unit_exercises_table_present(cur):
cur.execute(
"""SELECT tue.exercise_id, tu.id AS unit_id, tu.planned_date,
COALESCE(tg.name, '') AS group_name
FROM training_unit_exercises tue
JOIN training_units tu ON tu.id = tue.training_unit_id
LEFT JOIN training_groups tg ON tg.id = tu.group_id
WHERE tue.exercise_id = ANY(%s)""",
(list(exercise_ids),),
)
for row in cur.fetchall():
d = r2d(row)
eid = int(d["exercise_id"])
uid = int(d["unit_id"])
pd = d.get("planned_date")
planned: Optional[str]
if pd is None:
planned = None
elif hasattr(pd, "isoformat"):
planned = str(pd.isoformat())
else:
planned = str(pd)
exercise_to_units.setdefault(eid, []).append(
{
"id": uid,
"planned_date": planned,
"group_name": d.get("group_name") or "",
}
)
for eid, units in exercise_to_units.items():
seen_u: set[int] = set()
uniq: list[dict] = []
for u in units:
if u["id"] not in seen_u:
seen_u.add(u["id"])
uniq.append(u)
exercise_to_units[eid] = uniq
out: dict[int, dict[str, Any]] = {}
for aid in asset_ids:
aid_i = int(aid)
exs = ex_by_asset.get(aid_i, [])
seen_u2: set[int] = set()
units_agg: list[dict] = []
for ex in exs:
for u in exercise_to_units.get(ex["id"], []):
if u["id"] not in seen_u2:
seen_u2.add(u["id"])
units_agg.append(u)
out[aid_i] = {"exercises": exs, "training_units": units_agg}
return out
def _fetch_filter_uploaders(cur: Any, is_adm: bool, profile_id: int) -> list[dict]:
cur.execute(
"""SELECT DISTINCT ma.uploaded_by_profile_id AS id, pr.name, pr.email
FROM media_assets ma
LEFT JOIN profiles pr ON pr.id = ma.uploaded_by_profile_id
WHERE ma.uploaded_by_profile_id IS NOT NULL
AND ma.lifecycle_state IN ('active', 'trash_soft', 'trash_hidden')
AND (
%s
OR lower(trim(ma.visibility)) = 'official'
OR (
lower(trim(ma.visibility)) = 'private'
AND ma.uploaded_by_profile_id = %s
)
OR (
lower(trim(ma.visibility)) = 'club'
AND EXISTS (
SELECT 1 FROM club_members cm
WHERE cm.profile_id = %s
AND cm.club_id = ma.club_id
AND cm.status = 'active'
)
)
)
ORDER BY pr.name NULLS LAST, ma.uploaded_by_profile_id
LIMIT 400""",
(is_adm, profile_id, profile_id),
)
rows = [r2d(x) for x in cur.fetchall()]
out: list[dict] = []
for r in rows:
pid = r.get("id")
if pid is None:
continue
label = (r.get("name") or "").strip()
if not label:
label = (r.get("email") or "").strip()
if not label:
label = f"Profil #{pid}"
out.append({"id": int(pid), "label": label})
return out
def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict: def _effective_media_patch_fields(patch_fields: dict, asset: dict) -> dict:
@ -264,29 +453,111 @@ def list_media_assets(
"active", "active",
description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)", description="active | trash_soft | trash_hidden | all (nicht purgierte Zustände)",
), ),
media_kind: str = Query(
"all",
description="all | image | video | pdf | other",
),
club_id: Optional[int] = Query(
None,
ge=1,
description="Nur Superadmin: nach Verein filtern",
),
uploaded_by: Optional[int] = Query(
None,
ge=1,
description="Nach Uploader (Profil-ID), wenn erlaubt",
),
include_filter_meta: bool = Query(
False,
description="Uploader-Liste für Filter (nur wenn Metadaten sichtbar)",
),
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),
): ):
lc_where = _lifecycle_where_sql(lifecycle) lc_where = _lifecycle_where_sql(lifecycle)
mk = (media_kind or "all").strip().lower()
if mk not in _MEDIA_KIND_FILTERS:
raise HTTPException(status_code=400, detail="Ungültiger media_kind")
role = tenant.global_role or "" role = tenant.global_role or ""
is_adm = is_platform_admin(role) is_adm = is_platform_admin(role)
sup = is_superadmin(role)
profile_id = tenant.profile_id profile_id = tenant.profile_id
needle = (q or "").strip() needle = (q or "").strip()
params: list[Any] = [is_adm, profile_id, profile_id]
search_sql = "" if club_id is not None and not sup:
if needle: raise HTTPException(status_code=403, detail="Vereinsfilter nur für Superadmin")
like = f"%{needle}%"
params.extend([like, like]) media_kind_sql = ""
search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)" if mk == "image":
params.extend([limit, offset]) media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'image/%'"
elif mk == "video":
media_kind_sql = " AND lower(COALESCE(ma.mime_type, '')) LIKE 'video/%'"
elif mk == "pdf":
media_kind_sql = (
" AND (lower(COALESCE(ma.mime_type, '')) = 'application/pdf'"
" OR lower(COALESCE(ma.mime_type, '')) LIKE '%pdf%')"
)
elif mk == "other":
media_kind_sql = (
" AND COALESCE(ma.mime_type, '') <> ''"
" AND lower(ma.mime_type) NOT LIKE 'image/%'"
" AND lower(ma.mime_type) NOT LIKE 'video/%'"
" AND lower(ma.mime_type) <> 'application/pdf'"
" AND lower(ma.mime_type) NOT LIKE '%pdf%'"
)
club_sql = ""
club_sql_params: list[Any] = []
if club_id is not None:
club_sql = " AND ma.club_id = %s"
club_sql_params.append(club_id)
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") admin_club_ids = club_ids_for_profile_with_roles(cur, profile_id, "club_admin")
show_uploader = sup or is_adm or bool(admin_club_ids)
if uploaded_by is not None and not show_uploader:
raise HTTPException(status_code=403, detail="Uploader-Filter nicht erlaubt")
has_tags_col = _media_assets_tags_column_present(cur)
tags_select = "ma.tags," if has_tags_col else "ARRAY[]::text[] AS tags,"
uploaded_sql = ""
uploaded_params: list[Any] = []
if uploaded_by is not None:
uploaded_sql = " AND ma.uploaded_by_profile_id = %s"
uploaded_params.append(uploaded_by)
search_sql = ""
search_params: list[Any] = []
if needle:
like = f"%{needle}%"
if has_tags_col:
search_params = [like, like, like, like]
search_sql = (
" AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s"
" OR COALESCE(ma.copyright_notice, '') ILIKE %s"
" OR EXISTS (SELECT 1 FROM unnest(ma.tags) AS t WHERE t::text ILIKE %s))"
)
else:
search_params = [like, like, like]
search_sql = (
" AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s"
" OR COALESCE(ma.copyright_notice, '') ILIKE %s)"
)
params: list[Any] = (
[is_adm, profile_id, profile_id]
+ club_sql_params
+ uploaded_params
+ search_params
+ [limit, offset]
)
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.storage_key, ma.copyright_notice, ma.storage_key, {tags_select}
pr.name AS uploader_name, pr.name AS uploader_name,
pr.email AS uploader_email, pr.email AS uploader_email,
cl.name AS club_name cl.name AS club_name
@ -311,16 +582,27 @@ def list_media_assets(
) )
) )
) )
{club_sql}
{uploaded_sql}
{media_kind_sql}
{search_sql} {search_sql}
ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC
LIMIT %s OFFSET %s""", LIMIT %s OFFSET %s""",
params, params,
) )
rows = [r2d(r) for r in cur.fetchall()] rows = [r2d(r) for r in cur.fetchall()]
show_uploader = is_superadmin(role) or is_platform_admin(role) or bool(admin_club_ids) show_club = sup or is_adm
show_club = is_superadmin(role) or is_platform_admin(role) asset_ids = [int(r["id"]) for r in rows]
usage_map = _usage_for_media_assets(cur, asset_ids)
for r in rows: for r in rows:
r["permissions"] = _item_permissions(r, tenant, admin_club_ids) r["permissions"] = _item_permissions(r, tenant, admin_club_ids)
tid = int(r["id"])
r["usage"] = usage_map.get(tid, {"exercises": [], "training_units": []})
tags_val = r.get("tags")
if tags_val is None:
r["tags"] = []
elif not isinstance(tags_val, list):
r["tags"] = list(tags_val) if tags_val else []
if not show_uploader: if not show_uploader:
r["uploader_name"] = None r["uploader_name"] = None
r["uploader_email"] = None r["uploader_email"] = None
@ -329,16 +611,21 @@ def list_media_assets(
viewer = { viewer = {
"show_uploader_meta": show_uploader, "show_uploader_meta": show_uploader,
"show_club_meta": show_club, "show_club_meta": show_club,
"is_superadmin": is_superadmin(role), "is_superadmin": sup,
"is_platform_admin": is_platform_admin(role), "is_platform_admin": is_adm,
} }
filter_meta = None
if include_filter_meta and show_uploader:
filter_meta = {"uploaders": _fetch_filter_uploaders(cur, is_adm, profile_id)}
return { return {
"items": rows, "items": rows,
"limit": limit, "limit": limit,
"offset": offset, "offset": offset,
"lifecycle": lifecycle.strip().lower(), "lifecycle": lifecycle.strip().lower(),
"media_kind": mk,
"viewer": viewer, "viewer": viewer,
"filter_meta": filter_meta,
} }
@ -415,7 +702,11 @@ def bulk_media_patch(
tenant: TenantContext = Depends(get_tenant_context), tenant: TenantContext = Depends(get_tenant_context),
): ):
raw = body.model_dump(exclude_unset=True) if hasattr(body, "model_dump") else body.dict(exclude_unset=True) 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} patch_fields = {
k: v
for k, v in raw.items()
if k != "media_asset_ids" and (k == "tags" or v is not None)
}
if not patch_fields: if not patch_fields:
raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren")
@ -441,6 +732,15 @@ def bulk_media_patch(
asset = r2d(row) asset = r2d(row)
assert_can_edit_media_asset_metadata(cur, tenant, asset) assert_can_edit_media_asset_metadata(cur, tenant, asset)
if "tags" in patch_fields and not _media_assets_tags_column_present(cur):
failed.append(
{
"id": asset_id,
"detail": "Schlagwörter (tags) erfordern DB-Migration 046.",
}
)
continue
eff = _effective_media_patch_fields(patch_fields, asset) eff = _effective_media_patch_fields(patch_fields, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower() 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") next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
@ -461,6 +761,9 @@ def bulk_media_patch(
if "original_filename" in patch_fields: if "original_filename" in patch_fields:
sets.append("original_filename = %s") sets.append("original_filename = %s")
vals.append(patch_fields["original_filename"]) vals.append(patch_fields["original_filename"])
if "tags" in patch_fields:
sets.append("tags = %s")
vals.append(_normalize_media_tags(patch_fields["tags"]))
if "visibility" in patch_fields or "club_id" in patch_fields: if "visibility" in patch_fields or "club_id" in patch_fields:
sets.append("visibility = %s") sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
@ -509,6 +812,12 @@ def patch_media_asset(
asset = r2d(row) asset = r2d(row)
assert_can_edit_media_asset_metadata(cur, tenant, asset) assert_can_edit_media_asset_metadata(cur, tenant, asset)
if "tags" in data and not _media_assets_tags_column_present(cur):
raise HTTPException(
status_code=503,
detail="Schlagwörter (tags) erfordern die Datenbank-Migration 046. Bitte Migration ausführen.",
)
eff = _effective_media_patch_fields(data, asset) eff = _effective_media_patch_fields(data, asset)
next_vis = str(eff.get("visibility", asset["visibility"])).strip().lower() 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") next_cid = eff["club_id"] if "club_id" in eff else asset.get("club_id")
@ -529,6 +838,9 @@ def patch_media_asset(
if "original_filename" in data: if "original_filename" in data:
sets.append("original_filename = %s") sets.append("original_filename = %s")
vals.append(data["original_filename"]) vals.append(data["original_filename"])
if "tags" in data:
sets.append("tags = %s")
vals.append(_normalize_media_tags(data["tags"]))
if "visibility" in data or "club_id" in data: if "visibility" in data or "club_id" in data:
sets.append("visibility = %s") sets.append("visibility = %s")
vals.append(str(eff.get("visibility", asset["visibility"])).strip()) vals.append(str(eff.get("visibility", asset["visibility"])).strip())
@ -542,11 +854,22 @@ def patch_media_asset(
tuple(vals), tuple(vals),
) )
conn.commit() conn.commit()
cur.execute( has_tags = _media_assets_tags_column_present(cur)
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id, if has_tags:
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice cur.execute(
FROM media_assets WHERE id = %s""", """SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
(asset_id,), uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice, tags
) FROM media_assets WHERE id = %s""",
(asset_id,),
)
else:
cur.execute(
"""SELECT id, mime_type, byte_size, original_filename, visibility, club_id,
uploaded_by_profile_id, lifecycle_state, created_at, sha256, copyright_notice
FROM media_assets WHERE id = %s""",
(asset_id,),
)
out = r2d(cur.fetchone()) out = r2d(cur.fetchone())
return out if not has_tags:
out["tags"] = []
return out

View File

@ -2,8 +2,17 @@
""" """
Shinkan Jinkendo Datenbank-Migrationen Shinkan Jinkendo Datenbank-Migrationen
**Idempotent** über `schema_migrations`: jede numerische Datei `migrations/*.sql` höchstens einmal **Deployment:** Beim Start importiert `main.py` dieses Modul und ruft `main()` auf, bevor FastAPI
als erfolgreich eingetragen; bei erneutem Start werden nur noch fehlende Dateien abgearbeitet. die App lädt (`SKIP_DB_MIGRATE=1` nur für Tests oder ohne DB). Jeder Backend-Container-Start
wendet ausstehende Migrationen an kein separater Deploy-Schritt nötig.
**Idempotent (Runner):** Über `schema_migrations` wird jede Datei `NNN_*.sql` höchstens einmal
als erfolgreich markiert; wiederholte Starts führen nur noch fehlende Dateien aus.
**Idempotent (SQL, empfohlen):** `IF NOT EXISTS`, `ADD COLUMN IF NOT EXISTS`, `CREATE INDEX IF NOT EXISTS`,
damit ein erneuter Lauf derselben Datei harmlos bleibt. Ohne diese Guards schützt nur der
`schema_migrations`-Eintrag; ältere Migrationen ohne IF NOT EXISTS können bei manuell gelöschtem
Eintrag und bestehenden Tabellen fehlschlagen.
**Reihenfolge:** Alle `NNN_*.sql` nach führender Zahl (001 vor 009 vor 010 ), bei gleicher Zahl **Reihenfolge:** Alle `NNN_*.sql` nach führender Zahl (001 vor 009 vor 010 ), bei gleicher Zahl
alphabetisch nach Dateinamen nicht bloß String-Sortierung (vermeidet z.B. `10_` vor `9_`). alphabetisch nach Dateinamen nicht bloß String-Sortierung (vermeidet z.B. `10_` vor `9_`).

View File

@ -47,20 +47,28 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None:
) )
mock_cur = MagicMock() mock_cur = MagicMock()
mock_cur.fetchall.return_value = [ mock_cur.fetchall.side_effect = [
{ [
"id": 1, {
"mime_type": "image/png", "id": 1,
"byte_size": 100, "mime_type": "image/png",
"original_filename": "a.png", "byte_size": 100,
"visibility": "official", "original_filename": "a.png",
"club_id": None, "visibility": "official",
"uploaded_by_profile_id": 2, "club_id": None,
"lifecycle_state": "active", "uploaded_by_profile_id": 2,
"created_at": None, "lifecycle_state": "active",
"sha256": "a" * 64, "created_at": None,
"copyright_notice": None, "sha256": "a" * 64,
} "copyright_notice": None,
"storage_key": "media/a.png",
"tags": ["demo"],
"uploader_name": None,
"uploader_email": None,
"club_name": None,
}
],
[],
] ]
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)
@ -74,6 +82,8 @@ 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 body["items"][0]["usage"] == {"exercises": [], "training_units": []}
assert body["items"][0]["tags"] == ["demo"]
assert "viewer" in body assert "viewer" in body
@ -377,6 +387,32 @@ def test_list_media_assets_lifecycle_trash_soft_mocked(client: TestClient) -> No
assert list_sql_calls and "trash_soft" in list_sql_calls[0] assert list_sql_calls and "trash_soft" in list_sql_calls[0]
def test_list_media_assets_invalid_media_kind_400(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="trainer",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?media_kind=movies", headers={"X-Auth-Token": "t"})
assert r.status_code == 400
def test_list_media_assets_club_filter_forbidden_non_superadmin(client: TestClient) -> None:
app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "admin"}
app.dependency_overrides[get_tenant_context] = lambda: TenantContext(
profile_id=10,
global_role="admin",
effective_club_id=None,
club_ids=frozenset(),
memberships=[],
)
r = client.get("/api/media-assets?club_id=3", headers={"X-Auth-Token": "t"})
assert r.status_code == 403
def test_list_media_assets_invalid_lifecycle_400(client: TestClient) -> None: def test_list_media_assets_invalid_lifecycle_400(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(
@ -422,6 +458,7 @@ def test_patch_media_asset_copyright_mocked(client: TestClient) -> None:
"created_at": None, "created_at": None,
"sha256": "b" * 64, "sha256": "b" * 64,
"copyright_notice": "© HoldCo", "copyright_notice": "© HoldCo",
"tags": [],
}, },
] ]
mock_cm = _mock_db(mock_cur) mock_cm = _mock_db(mock_cur)

View File

@ -1,8 +1,8 @@
# Shinkan Jinkendo Version Information # Shinkan Jinkendo Version Information
APP_VERSION = "0.8.49" APP_VERSION = "0.8.51"
BUILD_DATE = "2026-05-07" BUILD_DATE = "2026-05-07"
DB_SCHEMA_VERSION = "20260507045" DB_SCHEMA_VERSION = "20260507046"
MODULE_VERSIONS = { MODULE_VERSIONS = {
"auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute) "auth": "1.2.1", # Login-Rate-Limit 30/minute pro IP (vorher 5/minute)
@ -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.4.0", # Manager: RBAC trash_soft Trainer nur privat; purge nur Superadmin; Superadmin force/hard-delete; Liste + permissions + JOINs; bulk lifecycle/patch "media_assets": "1.5.1", # usage: training_unit_exercises optional (Schema ohne planning-Tabelle)
"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,20 @@ MODULE_VERSIONS = {
} }
CHANGELOG = [ CHANGELOG = [
{
"version": "0.8.51",
"date": "2026-05-07",
"changes": [
"Medienbibliothek GET: Verknüpfung zu Trainingseinheiten nur wenn Tabelle training_unit_exercises existiert (ältere/kaputte Schemas → keine 500, Einheiten-Liste leer)",
],
},
{
"version": "0.8.50",
"date": "2026-05-07",
"changes": [
"Medienbibliothek: DB 046 media_assets.tags (GIN); GET filter media_kind, club_id (Superadmin), uploaded_by; Suche über Copyright & Tags; Antwort usage exercises + training_units; PATCH/Bulk tags; UI volle Breite, Filterleiste, ©-Symbol auf Kacheln, Links zu Übungen/Planung, gleiche Sichtbarkeits-/Status-Symbole wie Übungsliste",
],
},
{ {
"version": "0.8.49", "version": "0.8.49",
"date": "2026-05-07", "date": "2026-05-07",

View File

@ -5461,8 +5461,10 @@ a.analysis-split__nav-item {
max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px)); max(24px, env(safe-area-inset-bottom, 0px) + 8px) max(16px, env(safe-area-inset-left, 0px));
} }
.media-library__container { .media-library__container {
max-width: 1200px; width: 100%;
margin: 0 auto; max-width: none;
margin: 0;
box-sizing: border-box;
} }
.media-library__hero { .media-library__hero {
margin-bottom: 1.25rem; margin-bottom: 1.25rem;
@ -5531,6 +5533,74 @@ a.analysis-split__nav-item {
gap: 12px; gap: 12px;
font-size: 0.875rem; font-size: 0.875rem;
} }
.media-library__filters-row {
display: flex;
flex-wrap: wrap;
gap: 10px;
align-items: center;
margin-top: 12px;
}
.media-library__filters-row .form-input,
.media-library__filters-row select.form-input {
min-width: 0;
flex: 1 1 140px;
max-width: 240px;
}
.media-library__card-copyright {
position: absolute;
right: 6px;
bottom: 6px;
z-index: 2;
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border-radius: 8px;
background: rgba(255, 255, 255, 0.92);
color: var(--text2);
box-shadow: 0 1px 4px rgba(0, 0, 0, 0.1);
pointer-events: none;
}
.media-library__card-footer-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
margin-top: 8px;
flex-wrap: wrap;
}
.media-library__tag-chips {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.media-library__tag-chip {
font-size: 0.65rem;
padding: 2px 6px;
border-radius: 6px;
background: var(--surface2);
color: var(--text2);
max-width: 100%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.media-library__usage-links {
font-size: 0.72rem;
line-height: 1.45;
margin-top: 8px;
color: var(--text2);
}
.media-library__usage-links a {
color: var(--accent-dark, #2563eb);
text-decoration: none;
margin-right: 8px;
}
.media-library__usage-links a:hover {
text-decoration: underline;
}
.media-library__check-all { .media-library__check-all {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
@ -5765,9 +5835,10 @@ a.analysis-split__nav-item {
max-width: 160px; max-width: 160px;
} }
@media (max-width: 639px) { @media (max-width: 639px) {
.media-library__table .media-library__td-sub, .media-library__th-tags,
.media-library__table th:nth-child(5), .media-library__td-tags,
.media-library__table td:nth-child(5) { .media-library__th-usage,
.media-library__td-usage {
display: none; display: none;
} }
} }

View File

@ -5,6 +5,14 @@ import {
List, List,
MoreVertical, MoreVertical,
X, X,
Globe,
Users,
Lock,
CheckCircle2,
Archive,
CircleDot,
FilePenLine,
Copyright,
} from 'lucide-react' } from 'lucide-react'
import { useAuth } from '../context/AuthContext' import { useAuth } from '../context/AuthContext'
import api from '../utils/api' import api from '../utils/api'
@ -24,11 +32,110 @@ const VIS_OPTIONS = [
{ value: 'official', label: 'Offiziell' }, { value: 'official', label: 'Offiziell' },
] ]
function lcLabel(code) { const MEDIA_KIND_OPTIONS = [
{ value: 'all', label: 'Alle Typen' },
{ value: 'image', label: 'Bild' },
{ value: 'video', label: 'Video' },
{ value: 'pdf', label: 'PDF' },
{ value: 'other', label: 'Sonstiges' },
]
const LC_STATUS_LABELS = {
active: 'Aktiv',
trash_soft: 'Papierkorb (1)',
trash_hidden: 'Ausgeblendet (2)',
}
function visibilityUiLabel(v) {
const o = VIS_OPTIONS.find((x) => x.value === (v || '').toLowerCase())
return o ? o.label : v || '—'
}
function MediaCardScopeStatus({ visibility, lifecycleState }) {
const v = (visibility || 'private').toLowerCase()
const lc = (lifecycleState || 'active').toLowerCase()
const visLabel = visibilityUiLabel(v)
const lcLabel = LC_STATUS_LABELS[lc] || lcLabelFromOptions(lc)
const tip = `${visLabel} · ${lcLabel}`
let VisIcon = Lock
if (v === 'official') VisIcon = Globe
else if (v === 'club') VisIcon = Users
let LcIcon = FilePenLine
if (lc === 'active') LcIcon = CheckCircle2
else if (lc === 'archived' || lc === 'trash_hidden') LcIcon = Archive
else if (lc === 'in_review' || lc === 'trash_soft') LcIcon = CircleDot
return (
<div
className="exercise-card__meta-compact"
title={tip}
aria-label={`Sichtbarkeit: ${visLabel}. Lebenszyklus: ${lcLabel}.`}
>
<span className="exercise-card__meta-glyph">
<VisIcon size={15} strokeWidth={2} aria-hidden />
</span>
<span className="exercise-card__meta-sep" aria-hidden>
·
</span>
<span className="exercise-card__meta-glyph">
<LcIcon size={15} strokeWidth={2} aria-hidden />
</span>
</div>
)
}
function lcLabelFromOptions(code) {
const o = LC_OPTIONS.find((x) => x.value === code) const o = LC_OPTIONS.find((x) => x.value === code)
return o ? o.label : code return o ? o.label : code
} }
function lcLabel(code) {
return lcLabelFromOptions(code)
}
function parseTagsInput(s) {
return String(s || '')
.split(/[,;\n]+/)
.map((x) => x.trim())
.filter(Boolean)
}
function MediaUsageBlock({ usage, compact }) {
const u = usage || { exercises: [], training_units: [] }
const ex = u.exercises || []
const tus = u.training_units || []
if (!ex.length && !tus.length)
return <span className="media-library__hint">{compact ? '—' : 'Noch in keiner Übung / Einheit verknüpft.'}</span>
return (
<div className="media-library__usage-links">
{ex.length ? (
<div>
<strong>Übungen</strong>{' '}
{ex.map((e) => (
<Link key={e.id} to={`/exercises/${e.id}`} title={e.title}>
{e.title.length > (compact ? 18 : 40) ? `${e.title.slice(0, compact ? 18 : 40)}` : e.title}
</Link>
))}
</div>
) : null}
{tus.length ? (
<div style={{ marginTop: ex.length ? 6 : 0 }}>
<strong>Trainings­einheiten</strong>{' '}
{tus.map((t) => {
const label =
[t.planned_date, (t.group_name || '').trim()].filter(Boolean).join(' · ') || `Einheit #${t.id}`
const short = label.length > (compact ? 20 : 36) ? `${label.slice(0, compact ? 20 : 36)}` : label
return (
<Link key={t.id} to={`/planning?unit=${t.id}`} title={label}>
{short}
</Link>
)
})}
</div>
) : null}
</div>
)
}
function uploaderLabel(it, viewer) { function uploaderLabel(it, viewer) {
if (!viewer?.show_uploader_meta) return null if (!viewer?.show_uploader_meta) return null
const n = (it.uploader_name || '').trim() const n = (it.uploader_name || '').trim()
@ -111,6 +218,10 @@ export default function MediaLibraryPage() {
const [bulkApplyVis, setBulkApplyVis] = useState(false) const [bulkApplyVis, setBulkApplyVis] = useState(false)
const [busy, setBusy] = useState(false) const [busy, setBusy] = useState(false)
const [preview, setPreview] = useState(null) const [preview, setPreview] = useState(null)
const [mediaKind, setMediaKind] = useState('all')
const [filterClubId, setFilterClubId] = useState('')
const [filterUploaderId, setFilterUploaderId] = useState('')
const [uploaderFilterOptions, setUploaderFilterOptions] = useState([])
const loadClubs = useCallback(async () => { const loadClubs = useCallback(async () => {
try { try {
@ -134,23 +245,34 @@ export default function MediaLibraryPage() {
q: q.trim(), q: q.trim(),
limit: 100, limit: 100,
offset: 0, offset: 0,
media_kind: mediaKind,
include_filter_meta: true,
...(isSuperadmin && filterClubId
? { club_id: Number(filterClubId) }
: {}),
...(filterUploaderId && viewer?.show_uploader_meta
? { uploaded_by: Number(filterUploaderId) }
: {}),
}) })
setItems(res.items || []) setItems(res.items || [])
setViewer(res.viewer || null) setViewer(res.viewer || null)
if (res.filter_meta?.uploaders?.length) {
setUploaderFilterOptions(res.filter_meta.uploaders)
}
setSelected(new Set()) setSelected(new Set())
} catch (e) { } catch (e) {
setError(e.message || String(e)) setError(e.message || String(e))
} finally { } finally {
setLoading(false) setLoading(false)
} }
}, [lifecycle, q]) }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, isSuperadmin, viewer?.show_uploader_meta])
useEffect(() => { useEffect(() => {
const t = setTimeout(() => { const t = setTimeout(() => {
loadItems() loadItems()
}, 320) }, 320)
return () => clearTimeout(t) return () => clearTimeout(t)
}, [lifecycle, q, loadItems]) }, [lifecycle, q, mediaKind, filterClubId, filterUploaderId, loadItems])
useEffect(() => { useEffect(() => {
if (!preview) return if (!preview) return
@ -182,6 +304,7 @@ export default function MediaLibraryPage() {
setModalDraft({ setModalDraft({
display_name: it.original_filename != null ? String(it.original_filename) : '', display_name: it.original_filename != null ? String(it.original_filename) : '',
copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '', copyright_notice: it.copyright_notice != null ? String(it.copyright_notice) : '',
tags_input: Array.isArray(it.tags) ? it.tags.join(', ') : '',
visibility: (it.visibility || 'private').toLowerCase(), visibility: (it.visibility || 'private').toLowerCase(),
club_id: it.club_id != null ? String(it.club_id) : '', club_id: it.club_id != null ? String(it.club_id) : '',
superTarget: 'active', superTarget: 'active',
@ -202,6 +325,7 @@ export default function MediaLibraryPage() {
if (p.edit_metadata) { if (p.edit_metadata) {
body.original_filename = modalDraft.display_name body.original_filename = modalDraft.display_name
body.copyright_notice = modalDraft.copyright_notice body.copyright_notice = modalDraft.copyright_notice
body.tags = parseTagsInput(modalDraft.tags_input)
} }
if (p.change_visibility) { if (p.change_visibility) {
body.visibility = modalDraft.visibility body.visibility = modalDraft.visibility
@ -318,8 +442,9 @@ export default function MediaLibraryPage() {
</div> </div>
</div> </div>
<p className="media-library__intro"> <p className="media-library__intro">
Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Vorschau: Bild oder Video groß Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner,
anklicken. Bearbeiten und Papierkorb über das Menü pro Medium Bulk unten in der Leiste. technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen.
Bearbeiten über das Menü Bulk in der unteren Leiste.
</p> </p>
</header> </header>
@ -328,7 +453,7 @@ export default function MediaLibraryPage() {
<input <input
type="search" type="search"
className="form-input media-library__search" className="form-input media-library__search"
placeholder="Suche Dateiname …" placeholder="Suche Bezeichner, Pfade, Copyright, Tags …"
value={q} value={q}
onChange={(e) => setQ(e.target.value)} onChange={(e) => setQ(e.target.value)}
aria-label="Suche" aria-label="Suche"
@ -372,6 +497,50 @@ export default function MediaLibraryPage() {
Aktualisieren Aktualisieren
</button> </button>
</div> </div>
<div className="media-library__filters-row" aria-label="Filter">
<select
className="form-input"
value={mediaKind}
onChange={(e) => setMediaKind(e.target.value)}
aria-label="Medientyp"
>
{MEDIA_KIND_OPTIONS.map((o) => (
<option key={o.value} value={o.value}>
{o.label}
</option>
))}
</select>
{isSuperadmin ? (
<select
className="form-input"
value={filterClubId}
onChange={(e) => setFilterClubId(e.target.value)}
aria-label="Verein (Filter)"
>
<option value="">Alle Vereine</option>
{clubs.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || c.id}
</option>
))}
</select>
) : null}
{viewer?.show_uploader_meta ? (
<select
className="form-input"
value={filterUploaderId}
onChange={(e) => setFilterUploaderId(e.target.value)}
aria-label="Uploader"
>
<option value="">Alle Uploader</option>
{uploaderFilterOptions.map((u) => (
<option key={u.id} value={String(u.id)}>
{u.label}
</option>
))}
</select>
) : null}
</div>
<div className="media-library__toolbar-meta"> <div className="media-library__toolbar-meta">
<label className="media-library__check-all"> <label className="media-library__check-all">
<input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} /> <input type="checkbox" checked={items.length > 0 && selCount === items.length} onChange={selectAll} />
@ -400,7 +569,6 @@ export default function MediaLibraryPage() {
{viewMode === 'grid' && items.length > 0 ? ( {viewMode === 'grid' && items.length > 0 ? (
<div className="media-library__grid"> <div className="media-library__grid">
{items.map((it) => { {items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
const chk = selected.has(it.id) const chk = selected.has(it.id)
return ( return (
<div key={it.id} className="media-library__card"> <div key={it.id} className="media-library__card">
@ -424,16 +592,34 @@ export default function MediaLibraryPage() {
> >
<div className="media-library__card-thumb-wrap"> <div className="media-library__card-thumb-wrap">
<MediaThumb mediaId={it.id} mimeType={it.mime_type} /> <MediaThumb mediaId={it.id} mimeType={it.mime_type} />
{(it.copyright_notice || '').trim() ? (
<span
className="media-library__card-copyright"
title={(it.copyright_notice || '').trim()}
aria-label="Copyright-Eintrag vorhanden"
>
<Copyright size={14} strokeWidth={2} aria-hidden />
</span>
) : null}
</div> </div>
</button> </button>
<div className="media-library__card-footer"> <div className="media-library__card-footer">
<div className="media-library__card-name" title={it.original_filename || `#${it.id}`}> <div className="media-library__card-name" title={it.original_filename || `#${it.id}`}>
{it.original_filename || `Medium #${it.id}`} {it.original_filename || `Medium #${it.id}`}
</div> </div>
<div className="media-library__card-tags"> <div className="media-library__card-footer-row">
<span>{it.visibility}</span> <MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
<span>{lcLabel(lc)}</span>
</div> </div>
{(it.tags || []).length ? (
<div className="media-library__tag-chips">
{(it.tags || []).slice(0, 6).map((tg) => (
<span key={tg} className="media-library__tag-chip">
{tg}
</span>
))}
</div>
) : null}
<MediaUsageBlock usage={it.usage} compact />
</div> </div>
</div> </div>
) )
@ -449,8 +635,9 @@ export default function MediaLibraryPage() {
<th className="media-library__th-check" /> <th className="media-library__th-check" />
<th>Vorschau</th> <th>Vorschau</th>
<th>Bezeichnung</th> <th>Bezeichnung</th>
<th>Sichtbarkeit</th> <th>Kennzeichen</th>
<th>Status</th> <th className="media-library__th-tags">Tags</th>
<th className="media-library__th-usage">Verwendung</th>
{viewer?.show_club_meta ? <th>Verein</th> : null} {viewer?.show_club_meta ? <th>Verein</th> : null}
{viewer?.show_uploader_meta ? <th>Uploader</th> : null} {viewer?.show_uploader_meta ? <th>Uploader</th> : null}
<th className="media-library__th-act" /> <th className="media-library__th-act" />
@ -458,7 +645,6 @@ export default function MediaLibraryPage() {
</thead> </thead>
<tbody> <tbody>
{items.map((it) => { {items.map((it) => {
const lc = (it.lifecycle_state || '').toLowerCase()
return ( return (
<tr key={it.id}> <tr key={it.id}>
<td> <td>
@ -477,8 +663,15 @@ export default function MediaLibraryPage() {
</button> </button>
</td> </td>
<td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td> <td className="media-library__td-name">{it.original_filename || `#${it.id}`}</td>
<td>{it.visibility}</td> <td className="media-library__td-glyphs">
<td>{lcLabel(lc)}</td> <MediaCardScopeStatus visibility={it.visibility} lifecycleState={it.lifecycle_state} />
</td>
<td className="media-library__td-tags media-library__td-sub">
{(it.tags || []).length ? (it.tags || []).join(', ') : '—'}
</td>
<td className="media-library__td-usage media-library__td-sub">
<MediaUsageBlock usage={it.usage} compact />
</td>
{viewer?.show_club_meta ? ( {viewer?.show_club_meta ? (
<td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td> <td className="media-library__td-sub">{it.club_name || it.club_id || '—'}</td>
) : null} ) : null}
@ -727,6 +920,13 @@ export default function MediaLibraryPage() {
value={modalDraft.copyright_notice} value={modalDraft.copyright_notice}
onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))} onChange={(e) => setModalDraft((d) => ({ ...d, copyright_notice: e.target.value }))}
/> />
<label className="form-label">Schlagwörter (kommagetrennt)</label>
<input
className="form-input"
value={modalDraft.tags_input}
onChange={(e) => setModalDraft((d) => ({ ...d, tags_input: e.target.value }))}
placeholder="z. B. Technik, Wurf"
/>
</> </>
) : ( ) : (
<p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p> <p className="media-library__hint">Keine Berechtigung für Metadaten nur Verwaltende dieser Stufe.</p>
@ -766,6 +966,20 @@ export default function MediaLibraryPage() {
</> </>
) : null} ) : null}
<div className="media-library__meta-block">
<span className="media-library__meta-k">Sichtbarkeit / Lebenszyklus</span>
<div className="media-library__meta-v" style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<MediaCardScopeStatus visibility={modal.visibility} lifecycleState={modal.lifecycle_state} />
</div>
</div>
<div className="media-library__meta-block">
<span className="media-library__meta-k">Verwendung</span>
<div className="media-library__meta-v">
<MediaUsageBlock usage={modal.usage} compact={false} />
</div>
</div>
<div className="media-library__modal-actions"> <div className="media-library__modal-actions">
<button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}> <button type="button" className="btn btn-secondary" disabled={busy} onClick={saveModal}>
Speichern Speichern

View File

@ -535,6 +535,10 @@ export async function listMediaAssets(params = {}) {
if (params.limit != null) sp.set('limit', String(params.limit)) if (params.limit != null) sp.set('limit', String(params.limit))
if (params.offset != null) sp.set('offset', String(params.offset)) if (params.offset != null) sp.set('offset', String(params.offset))
if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle)) if (params.lifecycle) sp.set('lifecycle', String(params.lifecycle))
if (params.media_kind) sp.set('media_kind', String(params.media_kind))
if (params.club_id != null && params.club_id !== '') sp.set('club_id', String(params.club_id))
if (params.uploaded_by != null && params.uploaded_by !== '') sp.set('uploaded_by', String(params.uploaded_by))
if (params.include_filter_meta) sp.set('include_filter_meta', 'true')
const qs = sp.toString() const qs = sp.toString()
return request(`/api/media-assets${qs ? `?${qs}` : ''}`) return request(`/api/media-assets${qs ? `?${qs}` : ''}`)
} }