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
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:
parent
7284c577d7
commit
ece08ec1a1
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user