diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index f05be4b..b2720e9 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -21,6 +21,7 @@ from db import get_db, get_cursor, r2d from club_tenancy import ( assert_valid_governance_visibility, can_manage_club_org, + can_plan_in_club, club_admin_shares_club_with_creator, has_club_role, is_platform_admin, @@ -331,13 +332,31 @@ def _assert_can_view_exercise_media( return ex -def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int): - cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,)) +def _assert_can_edit_exercise(cur, exercise_id: int, tenant: TenantContext) -> None: + """Übung inhaltlich bearbeiten: Ersteller, Plattform-Admin, oder Planungsberechtigter im Verein (club-Übungen).""" + profile_id = tenant.profile_id + role = tenant.global_role or "" + cur.execute( + "SELECT created_by, visibility, club_id FROM exercises WHERE id = %s", + (exercise_id,), + ) row = cur.fetchone() if not row: raise HTTPException(status_code=404, detail="Übung nicht gefunden") - if _row_created_by(row) != profile_id: - raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten") + rd = r2d(row) + owner = rd.get("created_by") + if owner is not None: + owner = int(owner) + if owner == profile_id: + return + if is_platform_admin(role): + return + ex_vis = (rd.get("visibility") or "private").strip().lower() + ex_cid_raw = rd.get("club_id") + ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None + if ex_vis == "club" and ex_cid is not None and can_plan_in_club(cur, profile_id, ex_cid, role): + return + raise HTTPException(status_code=403, detail="Keine Berechtigung für diese Übung") def _variant_equipment_json(changes: Optional[list]) -> str: @@ -1495,7 +1514,7 @@ def update_exercise( ): """ Aktualisiert eine Übung (Partial Update). - Nur Owner darf editieren. + Berechtigt: Ersteller, Plattform-Admin oder Nutzer mit Planungsrecht im Verein (Vereins-Übungen). """ profile_id = tenant.profile_id @@ -1510,11 +1529,11 @@ def update_exercise( if not row: raise HTTPException(status_code=404, detail="Übung nicht gefunden") - if _row_created_by(row) != profile_id: - raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren") + _assert_can_edit_exercise(cur, exercise_id, tenant) - ex_vis = (row.get("visibility") or "private").strip().lower() - ex_cid = row.get("club_id") + rd = r2d(row) + ex_vis = (rd.get("visibility") or "private").strip().lower() + ex_cid = rd.get("club_id") if ex_cid is not None: ex_cid = int(ex_cid) @@ -1635,7 +1654,7 @@ def reorder_exercise_variants( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) cur.execute( "SELECT id FROM exercise_variants WHERE exercise_id = %s", @@ -1669,7 +1688,7 @@ def create_exercise_variant( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) _validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id) eq_json = _variant_equipment_json(body.equipment_changes) @@ -1729,7 +1748,7 @@ def update_exercise_variant( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) old = _fetch_variant_row(cur, exercise_id, variant_id) if "variant_name" in data and data["variant_name"] is not None: @@ -1804,7 +1823,7 @@ def delete_exercise_variant( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) _fetch_variant_row(cur, exercise_id, variant_id) cur.execute( @@ -2016,7 +2035,7 @@ async def upload_exercise_media( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA: raise HTTPException( @@ -2205,7 +2224,7 @@ def reorder_exercise_media( ids = body.media_ids with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) cur.execute( "SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id", (exercise_id,), @@ -2239,7 +2258,7 @@ def update_exercise_media( with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) if not _fetch_media_row(cur, exercise_id, media_id): raise HTTPException(status_code=404, detail="Medium nicht gefunden") if "context" in data and data["context"] not in ("ablauf", "detail", "trainer_hint", None): @@ -2279,7 +2298,7 @@ def delete_exercise_media( unlink_path: Optional[Path] = None with get_db() as conn: cur = get_cursor(conn) - _assert_can_edit_exercise(cur, exercise_id, profile_id) + _assert_can_edit_exercise(cur, exercise_id, tenant) media_root = get_effective_media_root(cur) cur.execute( """SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key diff --git a/frontend/src/pages/ExerciseFormPage.jsx b/frontend/src/pages/ExerciseFormPage.jsx index 5c7d024..6770898 100644 --- a/frontend/src/pages/ExerciseFormPage.jsx +++ b/frontend/src/pages/ExerciseFormPage.jsx @@ -367,6 +367,19 @@ function ExerciseFormPage() { const [mediaContext, setMediaContext] = useState('ablauf') const [embedUrl, setEmbedUrl] = useState('') const [embedTitle, setEmbedTitle] = useState('') + const [mediaFields, setMediaFields] = useState({}) + const [mediaSavingId, setMediaSavingId] = useState(null) + + useEffect(() => { + const next = {} + for (const m of mediaList) { + next[m.id] = { + title: m.title || '', + context: m.context || 'ablauf', + } + } + setMediaFields(next) + }, [mediaList]) useEffect(() => { let cancelled = false @@ -599,6 +612,43 @@ function ExerciseFormPage() { } } + const moveMediaRow = async (idx, dir) => { + if (!exerciseId) return + const j = idx + dir + if (j < 0 || j >= mediaList.length) return + const next = [...mediaList] + const tmp = next[idx] + next[idx] = next[j] + next[j] = tmp + try { + await api.reorderExerciseMedia( + exerciseId, + next.map((x) => x.id), + ) + setMediaList(next) + } catch (e) { + alert(e.message || String(e)) + } + } + + const saveMediaMeta = async (mid) => { + if (!exerciseId) return + const fld = mediaFields[mid] + if (!fld) return + setMediaSavingId(mid) + try { + await api.updateExerciseMedia(exerciseId, mid, { + title: fld.title.trim() || null, + context: fld.context, + }) + await refreshMedia() + } catch (e) { + alert(e.message || String(e)) + } finally { + setMediaSavingId(null) + } + } + const refreshVariants = async () => { if (!exerciseId) return const ex = await api.getExercise(exerciseId) @@ -1230,18 +1280,95 @@ function ExerciseFormPage() { {mediaList.length > 0 && ( -