diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index ee1e3c4..1add3d3 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -19,6 +19,9 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | 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 | Papierkorb: `trash_soft` / `trash_hidden` / `recover` / `purge`; Rechte über Uploader, `can_manage_club_org`, Superadmin (`assert_can_manage_media_asset_lifecycle`) | +| media_assets | `GET /api/media-assets` | ja | `get_tenant_context` | ja | Archiv-Liste: nur `lifecycle_state=active`; Sichtbarkeit wie Bibliothek (official / eigenes private / Vereinsmitglied) | +| media_assets | `GET /api/media-assets/{id}/file` | ja | `get_tenant_context_flexible` | ja | Direkt-Download für Archiv-Thumbs; `?ssetoken`; nur wenn Asset sichtbar | +| exercises | `POST /api/exercises/{id}/media/from-asset` | ja | `get_tenant_context` | ja | Verknüpfung `exercise_media` → bestehendes `media_asset_id`; Bearbeitungsrecht Übung + Leserecht Archiv | | auth | `/api/auth/*` | nein | — | Login/Session | EXEMPT | | catalogs | Katalog-CRUD | nein (global) | `require_auth` | Admin/Trainer je Endpoint | EXEMPT; bei späterem `club_id` nachziehen | | skills | `/api/skills*` | nein (global) | `require_auth` | je Endpoint | EXEMPT | @@ -30,7 +33,7 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. **Pflege / Drift:** Änderungen an Mandanten, Governance (`visibility`/`club_id`) oder neuen inhaltsbezogenen Endpoints → eine Zeile in dieser Tabelle anpassen und `PRODUCTION_READINESS_AUDIT_2026-05.md` prüfen. -Letzte Änderung: 2026-05-07 — `media_assets` Lifecycle-API (Papierkorb); `platform_media_storage` Admin-API (Speicherpfad Superadmin). +Letzte Änderung: 2026-05-07 — Medienarchiv `GET /api/media-assets`, `GET …/file`, Übung `POST …/media/from-asset`; Lifecycle siehe oben. --- diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 4d1ea01..f620aac 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -209,6 +209,17 @@ class ExerciseMediaReorder(BaseModel): media_ids: list[int] +class ExerciseMediaFromAsset(BaseModel): + """Bestehendes Archiv-Medium (media_assets) mit Übung verknüpfen — ohne erneuten Upload.""" + + media_asset_id: int = Field(..., ge=1) + title: Optional[str] = "" + description: Optional[str] = "" + context: str = "ablauf" + is_primary: bool = False + media_type: Optional[str] = None + + class ExerciseVariantCreate(BaseModel): variant_name: str = Field(..., min_length=3, max_length=200) description: Optional[str] = None @@ -302,6 +313,17 @@ def _detect_embed_platform(url: str) -> Optional[str]: return None +def _media_type_from_mime(mime: Optional[str]) -> str: + m = (mime or "").strip().lower() + if m.startswith("image/"): + return "image" + if m.startswith("video/"): + return "video" + if m == "application/pdf": + return "document" + return "document" + + def _fetch_exercise_governance_row(cur, exercise_id: int) -> Optional[dict]: cur.execute( "SELECT id, visibility, club_id, created_by FROM exercises WHERE id = %s", @@ -2222,6 +2244,115 @@ async def upload_exercise_media( return r2d(row) +@router.post("/exercises/{exercise_id}/media/from-asset", status_code=201) +def attach_exercise_media_from_asset( + exercise_id: int, + body: ExerciseMediaFromAsset, + tenant: TenantContext = Depends(get_tenant_context), +): + """Archiv-Medium an Übung anhängen (Wiederverwendung, deduplizierte Datei).""" + profile_id = tenant.profile_id + ctx = (body.context or "ablauf").strip() + if ctx not in ("ablauf", "detail", "trainer_hint"): + raise HTTPException(status_code=400, detail="Ungültiger context") + + mt_in = body.media_type + if mt_in is not None and mt_in not in ("image", "video", "document", "sketch"): + raise HTTPException(status_code=400, detail="Ungültiger media_type") + + with get_db() as conn: + cur = get_cursor(conn) + _assert_can_edit_exercise(cur, exercise_id, tenant) + + if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA: + raise HTTPException( + status_code=400, + detail=f"Maximal {MAX_EXERCISE_MEDIA} Medien pro Übung", + ) + + cur.execute( + """SELECT id, mime_type, byte_size, original_filename, visibility, club_id, + uploaded_by_profile_id, storage_key, lifecycle_state + FROM media_assets WHERE id = %s""", + (body.media_asset_id,), + ) + row = cur.fetchone() + if not row: + raise HTTPException(status_code=404, detail="Archiv-Medium nicht gefunden") + asset = r2d(row) + if (asset.get("lifecycle_state") or "").strip().lower() != "active": + raise HTTPException( + status_code=400, + detail="Nur aktive Archiv-Medien können verknüpft werden", + ) + + if not library_content_visible_to_profile( + cur, + profile_id, + (asset.get("visibility") or "").strip().lower(), + asset.get("club_id"), + asset.get("uploaded_by_profile_id"), + tenant.global_role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Archiv-Medium") + + cur.execute( + "SELECT 1 FROM exercise_media WHERE exercise_id = %s AND media_asset_id = %s", + (exercise_id, body.media_asset_id), + ) + if cur.fetchone(): + raise HTTPException( + status_code=400, + detail="Dieses Archiv-Medium ist bereits mit der Übung verknüpft", + ) + + sk = asset.get("storage_key") + if not sk: + raise HTTPException(status_code=400, detail="Archiv-Eintrag hat keine Datei") + + media_type = mt_in or _media_type_from_mime(asset.get("mime_type")) + title = (body.title or "").strip() or None + if title is None and asset.get("original_filename"): + title = str(asset.get("original_filename") or "")[:300] or None + description = (body.description or "").strip() or None + db_path = f"/media/{sk}" + + sort_sql = ( + "COALESCE((SELECT MAX(sort_order) + 1 FROM exercise_media WHERE exercise_id = %s), 1)" + ) + cur.execute( + f"""INSERT INTO exercise_media ( + exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, context, is_primary, sort_order, + media_asset_id + ) VALUES ( + %s, %s, %s, %s, %s, %s, NULL, NULL, %s, %s, %s, %s, {sort_sql}, %s + ) + RETURNING id, exercise_id, media_type, file_path, file_size, mime_type, original_filename, + embed_url, embed_platform, title, description, sort_order, is_primary, context, created_at, + media_asset_id""", + ( + exercise_id, + media_type, + db_path, + asset.get("byte_size"), + asset.get("mime_type"), + asset.get("original_filename"), + title, + description, + ctx, + body.is_primary, + exercise_id, + body.media_asset_id, + ), + ) + out = r2d(cur.fetchone()) + conn.commit() + + out["asset_lifecycle_state"] = "active" + return out + + @router.put("/exercises/{exercise_id}/media/reorder") def reorder_exercise_media( exercise_id: int, diff --git a/backend/routers/media_assets.py b/backend/routers/media_assets.py index bde4a95..b4eb10a 100644 --- a/backend/routers/media_assets.py +++ b/backend/routers/media_assets.py @@ -1,21 +1,17 @@ -"""Lifecycle für media_assets (Papierkorb) — MEDIA_ASSETS_AND_ARCHIVE_SPEC §5.""" +"""Medien-Archiv (Liste, Datei) und Lifecycle — MEDIA_ASSETS_AND_ARCHIVE_SPEC.""" from __future__ import annotations -from typing import Literal +from typing import Any, Literal, Optional + +from fastapi import APIRouter, Depends, HTTPException, Query, Request -from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel -from db import get_db, get_cursor -from tenant_context import TenantContext, get_tenant_context -from media_lifecycle import ( - assert_can_manage_media_asset_lifecycle, - fetch_media_asset_row, - purge_media_asset, - transition_recover_from_hidden, - transition_to_trash_hidden, - transition_to_trash_soft, -) +from club_tenancy import is_platform_admin, library_content_visible_to_profile +from db import get_db, get_cursor, r2d +from media_lifecycle import fetch_media_asset_row +from media_storage import get_effective_media_root, path_under_media_root +from tenant_context import TenantContext, get_tenant_context, get_tenant_context_flexible router = APIRouter(prefix="/api/media-assets", tags=["media-assets"]) @@ -24,13 +20,132 @@ class MediaLifecycleBody(BaseModel): action: Literal["trash_soft", "trash_hidden", "recover", "purge"] +def _fetch_asset_file_row(cur: Any, asset_id: int) -> Optional[dict]: + cur.execute( + """SELECT id, visibility, club_id, uploaded_by_profile_id, lifecycle_state, + storage_key, mime_type, original_filename + FROM media_assets WHERE id = %s""", + (asset_id,), + ) + row = cur.fetchone() + return r2d(row) if row else None + + +def _assert_can_view_archive_asset(cur: Any, tenant: TenantContext, asset: dict) -> None: + if not library_content_visible_to_profile( + cur, + tenant.profile_id, + (asset.get("visibility") or "").strip().lower(), + asset.get("club_id"), + asset.get("uploaded_by_profile_id"), + tenant.global_role, + ): + raise HTTPException(status_code=403, detail="Keine Berechtigung für dieses Medium") + + +@router.get("") +def list_media_assets( + tenant: TenantContext = Depends(get_tenant_context), + q: Optional[str] = Query(None, max_length=120), + limit: int = Query(30, ge=1, le=100), + offset: int = Query(0, ge=0), +): + """ + Durchsuchbares Medien-Archiv: nur aktive Assets, Sichtbarkeit wie Übungsbibliothek. + """ + role = tenant.global_role or "" + is_adm = is_platform_admin(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]) + + with get_db() as conn: + cur = get_cursor(conn) + 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 + FROM media_assets ma + WHERE ma.lifecycle_state = 'active' + 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' + ) + ) + ) + {search_sql} + ORDER BY ma.created_at DESC + LIMIT %s OFFSET %s""", + params, + ) + rows = [r2d(r) for r in cur.fetchall()] + return {"items": rows, "limit": limit, "offset": offset} + + +@router.api_route("/{asset_id}/file", methods=["GET", "HEAD"]) +def download_media_asset_file( + request: Request, + asset_id: int, + tenant: TenantContext = Depends(get_tenant_context_flexible), +): + """Direktzugriff auf Archiv-Datei (Thumbnail/Vorschau); Auth wie Übungs-Medien (?ssetoken).""" + from routers.exercises import _binary_media_response + + with get_db() as conn: + cur = get_cursor(conn) + asset = _fetch_asset_file_row(cur, asset_id) + if not asset: + raise HTTPException(status_code=404, detail="Medium nicht gefunden") + lc = (asset.get("lifecycle_state") or "").strip().lower() + if lc != "active": + raise HTTPException(status_code=404, detail="Medium nicht verfügbar") + _assert_can_view_archive_asset(cur, tenant, asset) + + sk = asset.get("storage_key") + if not sk: + raise HTTPException(status_code=404, detail="Keine Datei hinterlegt") + + media_root = get_effective_media_root(cur) + abs_p = path_under_media_root(media_root, str(sk)) + if not abs_p or not abs_p.is_file(): + raise HTTPException(status_code=404, detail="Datei nicht gefunden") + + mime = asset.get("mime_type") or "application/octet-stream" + fname = asset.get("original_filename") or abs_p.name + return _binary_media_response(abs_p, mime, str(fname) if fname else None, request) + + @router.post("/{asset_id}/lifecycle") def post_media_asset_lifecycle( asset_id: int, body: MediaLifecycleBody, tenant: TenantContext = Depends(get_tenant_context), ): - """Papierkorb-Übergänge (manuell). Rechte gemäß Spec §5.2.""" + """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) asset = fetch_media_asset_row(cur, asset_id) diff --git a/backend/version.py b/backend/version.py index e78b410..4194aee 100644 --- a/backend/version.py +++ b/backend/version.py @@ -1,6 +1,6 @@ # Shinkan Jinkendo Version Information -APP_VERSION = "0.8.42" +APP_VERSION = "0.8.43" BUILD_DATE = "2026-05-07" DB_SCHEMA_VERSION = "20260507045" @@ -13,11 +13,11 @@ 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.0.0", # POST /api/media-assets/{id}/lifecycle (Papierkorb §5) + "media_assets": "1.1.0", # GET Liste + GET …/file (Archiv); POST …/lifecycle "groups": "0.1.0", "skills": "0.1.0", "methods": "0.1.0", - "exercises": "2.12.0", # Detail/Liste: asset_lifecycle_state; Download trash_hidden nur für Bearbeiter; Lesen filtert hidden + "exercises": "2.13.0", # POST /media/from-asset (Archiv verknüpfen) "training_units": "0.2.0", "training_programs": "0.1.0", "planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile @@ -29,6 +29,15 @@ MODULE_VERSIONS = { } CHANGELOG = [ + { + "version": "0.8.43", + "date": "2026-05-07", + "changes": [ + "Medienarchiv: GET /api/media-assets (Suche, nur aktive Assets, Bibliotheks-Sichtbarkeit); GET /api/media-assets/{id}/file (Thumbnails/Vorschau, ssetoken)", + "Übungen: POST /api/exercises/{id}/media/from-asset — bestehendes Archiv-Medium verknüpfen ohne Upload-Duplikat", + "Frontend Übung bearbeiten: „Aus Archiv verknüpfen…“, Medienvorschau-Modal, Kachel-Thumbnails in der Medienliste", + ], + }, { "version": "0.8.42", "date": "2026-05-07", diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 3144dd9..94bf5b1 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -1,6 +1,7 @@ -import React, { useEffect, useState, useRef } from 'react' +import React, { useEffect, useState, useRef, useMemo } from 'react' import { useNavigate, useParams } from 'react-router-dom' import api, { buildExerciseApiPayload } from '../utils/api' +import { resolveExerciseMediaFileUrl, resolveMediaAssetFileUrl } from '../utils/exerciseMediaUrl' import RichTextEditor from '../components/RichTextEditor' import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel' import { SKILL_LEVEL_OPTIONS, normalizeSkillLevelSlug } from '../constants/skillLevels' @@ -369,6 +370,13 @@ function ExerciseFormPage() { const [embedTitle, setEmbedTitle] = useState('') const [mediaFields, setMediaFields] = useState({}) const [mediaSavingId, setMediaSavingId] = useState(null) + const [archiveOpen, setArchiveOpen] = useState(false) + const [archiveQ, setArchiveQ] = useState('') + const [archiveCtx, setArchiveCtx] = useState('ablauf') + const [archiveLoading, setArchiveLoading] = useState(false) + const [archiveItems, setArchiveItems] = useState([]) + const [archiveError, setArchiveError] = useState(null) + const [mediaPreview, setMediaPreview] = useState(null) useEffect(() => { const next = {} @@ -381,6 +389,30 @@ function ExerciseFormPage() { setMediaFields(next) }, [mediaList]) + useEffect(() => { + if (!archiveOpen) return undefined + let cancelled = false + const t = setTimeout(async () => { + setArchiveLoading(true) + setArchiveError(null) + try { + const res = await api.listMediaAssets({ + q: archiveQ.trim() || undefined, + limit: 40, + }) + if (!cancelled) setArchiveItems(res.items || []) + } catch (e) { + if (!cancelled) setArchiveError(e.message || String(e)) + } finally { + if (!cancelled) setArchiveLoading(false) + } + }, 280) + return () => { + cancelled = true + clearTimeout(t) + } + }, [archiveOpen, archiveQ]) + useEffect(() => { let cancelled = false const boot = async () => { @@ -558,6 +590,28 @@ function ExerciseFormPage() { setMediaList(ex.media || []) } + const attachFromArchive = async (assetId) => { + if (!exerciseId) return + try { + await api.attachExerciseMediaFromAsset(exerciseId, { + media_asset_id: assetId, + context: archiveCtx, + title: '', + description: '', + is_primary: false, + }) + setArchiveOpen(false) + await refreshMedia() + } catch (e) { + alert(e.message || String(e)) + } + } + + const linkedArchiveAssetIds = useMemo( + () => new Set((mediaList || []).map((m) => m.media_asset_id).filter(Boolean)), + [mediaList], + ) + const handleUploadFile = async () => { if (!exerciseId || !mediaFile) { alert('Datei wählen') @@ -1228,8 +1282,25 @@ function ExerciseFormPage() {
- Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. + Datei oder Embed (YouTube, Vimeo, Instagram, TikTok). Max. 10 pro Übung. Archiv-Medien können mehrfach + verknüpft werden.
++ Nur aktive Medien, die Sie lesen dürfen (offiziell, eigenes privat, Ihre Vereine). +
+ setArchiveQ(e.target.value)} + style={{ marginBottom: '8px' }} + /> + + {archiveLoading &&Laden…
} + {archiveError &&{archiveError}
} + {!archiveLoading && !archiveError && archiveItems.length === 0 && ( +Keine Treffer.
+ )} ++ + {mediaPreview.embed_url} + +
+ ) : mediaPreview.mime_type?.startsWith('video/') ? ( + + ) : mediaPreview.mime_type?.startsWith('image/') ? ( ++ + Datei öffnen + +
+ )} +