feat: enhance exercise editing permissions and media management
All checks were successful
Deploy Development / deploy (push) Successful in 35s
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 24s

- Updated exercise editing permissions to allow platform admins and users with planning rights in clubs to edit exercises, improving governance.
- Refactored the `_assert_can_edit_exercise` function to utilize tenant context for permission checks.
- Enhanced frontend media management with new features for saving media metadata and reordering media items, improving user experience.
- Introduced state management for media fields in the ExerciseFormPage, allowing users to edit titles and contexts for media assets.
This commit is contained in:
Lars 2026-05-07 12:46:59 +02:00
parent 7284c577d7
commit ece08ec1a1
2 changed files with 170 additions and 24 deletions

View File

@ -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

View File

@ -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() {
</div>
</div>
{mediaList.length > 0 && (
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
{mediaList.map((m) => (
<li key={m.id} style={{ marginBottom: '6px' }}>
{m.title || m.original_filename || m.media_type}{' '}
{m.embed_platform ? `(${m.embed_platform})` : ''}
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
{mediaList.map((m, idx) => (
<li
key={m.id}
className="card"
style={{
marginBottom: '10px',
padding: '10px 12px',
border: '1px solid var(--border)',
}}
>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: '8px', alignItems: 'center' }}>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
#{idx + 1} · {m.media_type}
{m.embed_platform ? ` · ${m.embed_platform}` : ''}
</span>
{mediaList.length > 1 && (
<>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx === 0}
onClick={() => moveMediaRow(idx, -1)}
title="Nach oben"
>
</button>
<button
type="button"
className="btn btn-secondary"
style={{ fontSize: '11px', padding: '2px 8px' }}
disabled={idx >= mediaList.length - 1}
onClick={() => moveMediaRow(idx, 1)}
title="Nach unten"
>
</button>
</>
)}
</div>
<div className="form-row" style={{ marginTop: '8px', display: 'grid', gap: '8px' }}>
<input
type="text"
className="form-input"
placeholder="Titel"
value={(mediaFields[m.id] || {}).title ?? ''}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: { ...(prev[m.id] || {}), title: e.target.value, context: (prev[m.id] || {}).context || 'ablauf' },
}))
}
/>
<select
className="form-input"
value={(mediaFields[m.id] || {}).context || 'ablauf'}
onChange={(e) =>
setMediaFields((prev) => ({
...prev,
[m.id]: {
...(prev[m.id] || {}),
title: (prev[m.id] || {}).title ?? '',
context: e.target.value,
},
}))
}
>
<option value="ablauf">Ablauf</option>
<option value="detail">Detail</option>
<option value="trainer_hint">Trainer-Hinweis</option>
</select>
<button
type="button"
className="btn btn-primary"
style={{ fontSize: '12px' }}
disabled={mediaSavingId === m.id}
onClick={() => saveMediaMeta(m.id)}
>
{mediaSavingId === m.id ? 'Speichern…' : 'Titel & Sektion speichern'}
</button>
</div>
<button
type="button"
className="btn"
style={{
marginLeft: '8px',
marginTop: '8px',
fontSize: '11px',
padding: '2px 8px',
padding: '4px 10px',
background: 'var(--danger)',
color: '#fff',
border: 'none',