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 (
|
from club_tenancy import (
|
||||||
assert_valid_governance_visibility,
|
assert_valid_governance_visibility,
|
||||||
can_manage_club_org,
|
can_manage_club_org,
|
||||||
|
can_plan_in_club,
|
||||||
club_admin_shares_club_with_creator,
|
club_admin_shares_club_with_creator,
|
||||||
has_club_role,
|
has_club_role,
|
||||||
is_platform_admin,
|
is_platform_admin,
|
||||||
|
|
@ -331,13 +332,31 @@ def _assert_can_view_exercise_media(
|
||||||
return ex
|
return ex
|
||||||
|
|
||||||
|
|
||||||
def _assert_can_edit_exercise(cur, exercise_id: int, profile_id: int):
|
def _assert_can_edit_exercise(cur, exercise_id: int, tenant: TenantContext) -> None:
|
||||||
cur.execute("SELECT created_by FROM exercises WHERE id = %s", (exercise_id,))
|
"""Ü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()
|
row = cur.fetchone()
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
if _row_created_by(row) != profile_id:
|
rd = r2d(row)
|
||||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf diese Übung bearbeiten")
|
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:
|
def _variant_equipment_json(changes: Optional[list]) -> str:
|
||||||
|
|
@ -1495,7 +1514,7 @@ def update_exercise(
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Aktualisiert eine Übung (Partial Update).
|
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
|
profile_id = tenant.profile_id
|
||||||
|
|
||||||
|
|
@ -1510,11 +1529,11 @@ def update_exercise(
|
||||||
if not row:
|
if not row:
|
||||||
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
raise HTTPException(status_code=404, detail="Übung nicht gefunden")
|
||||||
|
|
||||||
if _row_created_by(row) != profile_id:
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
raise HTTPException(status_code=403, detail="Nur der Ersteller darf editieren")
|
|
||||||
|
|
||||||
ex_vis = (row.get("visibility") or "private").strip().lower()
|
rd = r2d(row)
|
||||||
ex_cid = row.get("club_id")
|
ex_vis = (rd.get("visibility") or "private").strip().lower()
|
||||||
|
ex_cid = rd.get("club_id")
|
||||||
if ex_cid is not None:
|
if ex_cid is not None:
|
||||||
ex_cid = int(ex_cid)
|
ex_cid = int(ex_cid)
|
||||||
|
|
||||||
|
|
@ -1635,7 +1654,7 @@ def reorder_exercise_variants(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
"SELECT id FROM exercise_variants WHERE exercise_id = %s",
|
||||||
|
|
@ -1669,7 +1688,7 @@ def create_exercise_variant(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)
|
_validate_variant_prerequisite(cur, exercise_id, body.prerequisite_variant_id)
|
||||||
|
|
||||||
eq_json = _variant_equipment_json(body.equipment_changes)
|
eq_json = _variant_equipment_json(body.equipment_changes)
|
||||||
|
|
@ -1729,7 +1748,7 @@ def update_exercise_variant(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)
|
old = _fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
if "variant_name" in data and data["variant_name"] is not None:
|
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:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)
|
_fetch_variant_row(cur, exercise_id, variant_id)
|
||||||
|
|
||||||
cur.execute(
|
cur.execute(
|
||||||
|
|
@ -2016,7 +2035,7 @@ async def upload_exercise_media(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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:
|
if _count_exercise_media(cur, exercise_id) >= MAX_EXERCISE_MEDIA:
|
||||||
raise HTTPException(
|
raise HTTPException(
|
||||||
|
|
@ -2205,7 +2224,7 @@ def reorder_exercise_media(
|
||||||
ids = body.media_ids
|
ids = body.media_ids
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(conn)
|
cur = get_cursor(conn)
|
||||||
_assert_can_edit_exercise(cur, exercise_id, profile_id)
|
_assert_can_edit_exercise(cur, exercise_id, tenant)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id",
|
"SELECT id FROM exercise_media WHERE exercise_id = %s ORDER BY sort_order, id",
|
||||||
(exercise_id,),
|
(exercise_id,),
|
||||||
|
|
@ -2239,7 +2258,7 @@ def update_exercise_media(
|
||||||
|
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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):
|
if not _fetch_media_row(cur, exercise_id, media_id):
|
||||||
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
raise HTTPException(status_code=404, detail="Medium nicht gefunden")
|
||||||
if "context" in data and data["context"] not in ("ablauf", "detail", "trainer_hint", None):
|
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
|
unlink_path: Optional[Path] = None
|
||||||
with get_db() as conn:
|
with get_db() as conn:
|
||||||
cur = get_cursor(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)
|
media_root = get_effective_media_root(cur)
|
||||||
cur.execute(
|
cur.execute(
|
||||||
"""SELECT em.file_path, em.media_asset_id, ma.storage_key AS asset_storage_key
|
"""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 [mediaContext, setMediaContext] = useState('ablauf')
|
||||||
const [embedUrl, setEmbedUrl] = useState('')
|
const [embedUrl, setEmbedUrl] = useState('')
|
||||||
const [embedTitle, setEmbedTitle] = 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(() => {
|
useEffect(() => {
|
||||||
let cancelled = false
|
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 () => {
|
const refreshVariants = async () => {
|
||||||
if (!exerciseId) return
|
if (!exerciseId) return
|
||||||
const ex = await api.getExercise(exerciseId)
|
const ex = await api.getExercise(exerciseId)
|
||||||
|
|
@ -1230,18 +1280,95 @@ function ExerciseFormPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{mediaList.length > 0 && (
|
{mediaList.length > 0 && (
|
||||||
<ul style={{ marginTop: '12px', paddingLeft: '1.2rem' }}>
|
<ul style={{ marginTop: '12px', paddingLeft: '0', listStyle: 'none' }}>
|
||||||
{mediaList.map((m) => (
|
{mediaList.map((m, idx) => (
|
||||||
<li key={m.id} style={{ marginBottom: '6px' }}>
|
<li
|
||||||
{m.title || m.original_filename || m.media_type}{' '}
|
key={m.id}
|
||||||
{m.embed_platform ? `(${m.embed_platform})` : ''}
|
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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="btn"
|
className="btn"
|
||||||
style={{
|
style={{
|
||||||
marginLeft: '8px',
|
marginTop: '8px',
|
||||||
fontSize: '11px',
|
fontSize: '11px',
|
||||||
padding: '2px 8px',
|
padding: '4px 10px',
|
||||||
background: 'var(--danger)',
|
background: 'var(--danger)',
|
||||||
color: '#fff',
|
color: '#fff',
|
||||||
border: 'none',
|
border: 'none',
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user