diff --git a/backend/main.py b/backend/main.py index d3a6bfc..28714a7 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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_migrations: pending *.sql in einer Transaktion pro Datei, Buchführung schema_migrations) # Lokal ohne DB / nur Tests: SKIP_DB_MIGRATE=1 if os.getenv("SKIP_DB_MIGRATE", "").strip().lower() in ("1", "true", "yes"): print("[SKIP_DB_MIGRATE] Migrationen uebersprungen (nur fuer Entwicklung ohne DB)") diff --git a/backend/migrations/046_media_asset_tags.sql b/backend/migrations/046_media_asset_tags.sql new file mode 100644 index 0000000..5ece8eb --- /dev/null +++ b/backend/migrations/046_media_asset_tags.sql @@ -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); diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index e11cd09..4378131 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -1,7 +1,7 @@ """Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC.""" 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 pydantic import BaseModel, Field, model_validator @@ -63,6 +63,7 @@ class MediaAssetPatch(BaseModel): original_filename: Optional[str] = Field(None, max_length=300) visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") club_id: Optional[int] = None + tags: Optional[list[str]] = None class MediaBulkLifecycleBody(BaseModel): @@ -93,9 +94,197 @@ class MediaBulkPatchBody(BaseModel): original_filename: Optional[str] = Field(None, max_length=300) visibility: Optional[str] = Field(None, pattern="^(private|club|official)$") club_id: Optional[int] = None + tags: Optional[list[str]] = None _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: @@ -264,29 +453,111 @@ def list_media_assets( "active", 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), offset: int = Query(0, ge=0), ): 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 "" is_adm = is_platform_admin(role) + sup = is_superadmin(role) profile_id = tenant.profile_id needle = (q or "").strip() - params: list[Any] = [is_adm, profile_id, profile_id] - search_sql = "" - if needle: - like = f"%{needle}%" - params.extend([like, like]) - search_sql = " AND (ma.original_filename ILIKE %s OR ma.storage_key ILIKE %s)" - params.extend([limit, offset]) + + if club_id is not None and not sup: + raise HTTPException(status_code=403, detail="Vereinsfilter nur für Superadmin") + + media_kind_sql = "" + if mk == "image": + 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: cur = get_cursor(conn) 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( 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.storage_key, + ma.copyright_notice, ma.storage_key, {tags_select} pr.name AS uploader_name, pr.email AS uploader_email, cl.name AS club_name @@ -311,16 +582,27 @@ def list_media_assets( ) ) ) + {club_sql} + {uploaded_sql} + {media_kind_sql} {search_sql} ORDER BY ma.updated_at DESC NULLS LAST, ma.created_at DESC LIMIT %s OFFSET %s""", params, ) 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 = is_superadmin(role) or is_platform_admin(role) + show_club = sup or is_adm + asset_ids = [int(r["id"]) for r in rows] + usage_map = _usage_for_media_assets(cur, asset_ids) for r in rows: 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: r["uploader_name"] = None r["uploader_email"] = None @@ -329,16 +611,21 @@ def list_media_assets( viewer = { "show_uploader_meta": show_uploader, "show_club_meta": show_club, - "is_superadmin": is_superadmin(role), - "is_platform_admin": is_platform_admin(role), + "is_superadmin": sup, + "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 { "items": rows, "limit": limit, "offset": offset, "lifecycle": lifecycle.strip().lower(), + "media_kind": mk, "viewer": viewer, + "filter_meta": filter_meta, } @@ -415,7 +702,11 @@ def bulk_media_patch( 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} + 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: raise HTTPException(status_code=400, detail="Keine Felder zum Aktualisieren") @@ -441,6 +732,15 @@ def bulk_media_patch( asset = r2d(row) 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) 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") @@ -461,6 +761,9 @@ def bulk_media_patch( if "original_filename" in patch_fields: sets.append("original_filename = %s") 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: sets.append("visibility = %s") vals.append(str(eff.get("visibility", asset["visibility"])).strip()) @@ -509,6 +812,12 @@ def patch_media_asset( asset = r2d(row) 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) 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") @@ -529,6 +838,9 @@ def patch_media_asset( if "original_filename" in data: sets.append("original_filename = %s") 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: sets.append("visibility = %s") vals.append(str(eff.get("visibility", asset["visibility"])).strip()) @@ -542,11 +854,22 @@ def patch_media_asset( tuple(vals), ) conn.commit() - 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,), - ) + has_tags = _media_assets_tags_column_present(cur) + if has_tags: + cur.execute( + """SELECT id, mime_type, byte_size, original_filename, visibility, club_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()) - return out + if not has_tags: + out["tags"] = [] + return out diff --git a/backend/run_migrations.py b/backend/run_migrations.py index b68d5a5..35f0bbc 100644 --- a/backend/run_migrations.py +++ b/backend/run_migrations.py @@ -2,8 +2,17 @@ """ Shinkan Jinkendo — Datenbank-Migrationen -**Idempotent** über `schema_migrations`: jede numerische Datei `migrations/*.sql` höchstens einmal -als „erfolgreich“ eingetragen; bei erneutem Start werden nur noch fehlende Dateien abgearbeitet. +**Deployment:** Beim Start importiert `main.py` dieses Modul und ruft `main()` auf, bevor FastAPI +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 alphabetisch nach Dateinamen — nicht bloß String-Sortierung (vermeidet z. B. `10_` vor `9_`). diff --git a/backend/tests/test_media_assets_archive.py b/backend/tests/test_media_assets_archive.py index 1b2c03b..fac7839 100644 --- a/backend/tests/test_media_assets_archive.py +++ b/backend/tests/test_media_assets_archive.py @@ -47,20 +47,28 @@ def test_list_media_assets_ok_mocked(client: TestClient) -> None: ) mock_cur = MagicMock() - mock_cur.fetchall.return_value = [ - { - "id": 1, - "mime_type": "image/png", - "byte_size": 100, - "original_filename": "a.png", - "visibility": "official", - "club_id": None, - "uploaded_by_profile_id": 2, - "lifecycle_state": "active", - "created_at": None, - "sha256": "a" * 64, - "copyright_notice": None, - } + mock_cur.fetchall.side_effect = [ + [ + { + "id": 1, + "mime_type": "image/png", + "byte_size": 100, + "original_filename": "a.png", + "visibility": "official", + "club_id": None, + "uploaded_by_profile_id": 2, + "lifecycle_state": "active", + "created_at": 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) @@ -74,6 +82,8 @@ 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 body["items"][0]["usage"] == {"exercises": [], "training_units": []} + assert body["items"][0]["tags"] == ["demo"] 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] +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: app.dependency_overrides[require_auth] = lambda: {"profile_id": 10, "role": "trainer"} 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, "sha256": "b" * 64, "copyright_notice": "© HoldCo", + "tags": [], }, ] mock_cm = _mock_db(mock_cur) diff --git a/backend/version.py b/backend/version.py index a63a727..317abc7 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,8 +1,8 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.49" +APP_VERSION = "0.8.51" BUILD_DATE = "2026-05-07" -DB_SCHEMA_VERSION = "20260507045" +DB_SCHEMA_VERSION = "20260507046" MODULE_VERSIONS = { "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) "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.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", "skills": "0.1.0", "methods": "0.1.0", @@ -29,6 +29,20 @@ MODULE_VERSIONS = { } 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", "date": "2026-05-07", diff --git a/frontend/src/app.css b/frontend/src/app.css index a50bf27..cd7d141 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -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)); } .media-library__container { - max-width: 1200px; - margin: 0 auto; + width: 100%; + max-width: none; + margin: 0; + box-sizing: border-box; } .media-library__hero { margin-bottom: 1.25rem; @@ -5531,6 +5533,74 @@ a.analysis-split__nav-item { gap: 12px; 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 { display: inline-flex; align-items: center; @@ -5765,9 +5835,10 @@ a.analysis-split__nav-item { 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) { + .media-library__th-tags, + .media-library__td-tags, + .media-library__th-usage, + .media-library__td-usage { display: none; } } diff --git a/frontend/src/pages/MediaLibraryPage.jsx b/frontend/src/pages/MediaLibraryPage.jsx index 1f5519d..385d01d 100644 --- a/frontend/src/pages/MediaLibraryPage.jsx +++ b/frontend/src/pages/MediaLibraryPage.jsx @@ -5,6 +5,14 @@ import { List, MoreVertical, X, + Globe, + Users, + Lock, + CheckCircle2, + Archive, + CircleDot, + FilePenLine, + Copyright, } from 'lucide-react' import { useAuth } from '../context/AuthContext' import api from '../utils/api' @@ -24,11 +32,110 @@ const VIS_OPTIONS = [ { 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 ( +
- Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Vorschau: Bild oder Video groß - anklicken. Bearbeiten und Papierkorb über das Menü pro Medium — Bulk unten in der Leiste. + Veröffentlichte Medien (Verein/Plattform) und eigene Uploads. Suche durchsucht Bezeichner, + technischen Speicherpfad, Copyright-Text und Schlagwörter. Vorschau: Vorschaubild antippen. + Bearbeiten über das Menü — Bulk in der unteren Leiste.
@@ -328,7 +453,7 @@ export default function MediaLibraryPage() { setQ(e.target.value)} aria-label="Suche" @@ -372,6 +497,50 @@ export default function MediaLibraryPage() { Aktualisieren +