feat: implement bulk metadata update for exercises
Some checks failed
Deploy Development / deploy (push) Successful in 34s
Test Suite / lint-backend (push) Successful in 1s
Test Suite / build-frontend (push) Successful in 6s
Test Suite / playwright-tests (push) Failing after 35s

- Introduced a new PATCH endpoint `/api/exercises/bulk-metadata` to allow bulk updates of visibility and status for exercises, supporting up to 500 IDs.
- Enhanced the ExercisesListPage to include a bulk update modal for managing exercise visibility and status.
- Updated frontend API utility to handle bulk patch requests.
- Bumped application version to 0.8.29 and updated changelog to reflect these changes.
This commit is contained in:
Lars 2026-05-05 22:18:04 +02:00
parent e0ecfe927f
commit dc310b38eb
6 changed files with 473 additions and 7 deletions

View File

@ -9,7 +9,8 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe AC.
| clubs | geschützte `/api/clubs*`, `/divisions*`, `/groups*` | ja | `get_tenant_context` | Mitgliedschaft / `can_manage_*` | Öffentlich: `/clubs/public-directory` ohne Auth |
| club_memberships | `/clubs/{id}/members*` | ja | `get_tenant_context` | ja | |
| club_join_requests | `/me/club-join-requests`, `/clubs/{id}/join-requests*` | ja | `get_tenant_context` | ja | |
| exercises | alle geschützten `/api/exercises*` | ja | `get_tenant_context` | ja | |
| exercises | `PATCH /api/exercises/bulk-metadata` | ja | `get_tenant_context` | ja | Liste: UI-Mehrfachwahl; bis 500 IDs; nur Ersteller oder Plattform-Admin |
| exercises | übrige geschützte `/api/exercises*` | ja | `get_tenant_context` | ja | |
| exercise_progression_graphs | `/api/exercise-progression-graphs*` | ja | `get_tenant_context` | Liste wie Bibliothek; Schreiben Ersteller/Plattform-Admin | Kanten: Lesen wenn Graph lesbar |
| training_planning | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Vorlagen-Liste wie Übungen; POST Vorlage Default club_id |
| training_framework_programs | alle geschützten Endpoints | ja | `get_tenant_context` | ja | Liste + POST Default club_id |

View File

@ -9,7 +9,7 @@ import json
import logging
import os
from pathlib import Path
from typing import Optional
from typing import Any, Dict, List, Optional
from fastapi import APIRouter, HTTPException, Depends, Query, UploadFile, File, Form
from pydantic import BaseModel, Field, model_validator
@ -213,6 +213,25 @@ class ExerciseVariantsReorder(BaseModel):
variant_ids: list[int]
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500
class ExerciseBulkMetadataPatch(BaseModel):
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
exercise_ids: list[int] = Field(..., min_length=1, max_length=_MAX_BULK_METADATA_IDS)
visibility: Optional[str] = Field(None, pattern="^(private|club|official)$")
status: Optional[str] = None
club_id: Optional[int] = Field(default=None, ge=1)
@model_validator(mode="after")
def at_least_visibility_or_status(self):
if self.visibility is None and self.status is None:
raise ValueError("Mindestens eines der Felder visibility oder status angeben")
return self
# ============================================================================
# Helper Functions
# ============================================================================
@ -552,6 +571,127 @@ def _merge_str_any(multi: list[str], single: Optional[str]) -> list[str]:
return out
@router.patch("/exercises/bulk-metadata")
def bulk_patch_exercises_metadata(
body: ExerciseBulkMetadataPatch,
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
"""
profile_id = tenant.profile_id
role = tenant.global_role
unique_ids = sorted({int(x) for x in body.exercise_ids if x is not None and int(x) > 0})
if not unique_ids:
raise HTTPException(status_code=400, detail="Keine gültigen Übungs-IDs")
if len(unique_ids) > _MAX_BULK_METADATA_IDS:
raise HTTPException(
status_code=400,
detail=f"Maximal {_MAX_BULK_METADATA_IDS} Übungen pro Anfrage",
)
status_val: Optional[str] = None
if body.status is not None:
st = str(body.status).strip().lower()
if st not in _VALID_EXERCISE_STATUS_BULK:
raise HTTPException(status_code=400, detail="Ungültiger Status")
status_val = st
patch_visibility = body.visibility is not None
patch_status = status_val is not None
updated: List[int] = []
failed: List[Dict[str, Any]] = []
def _fail_msg(he: HTTPException) -> str:
d = he.detail
return d if isinstance(d, str) else str(d)
with get_db() as conn:
cur = get_cursor(conn)
for ex_id in unique_ids:
cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
(ex_id,),
)
row = cur.fetchone()
if not row:
failed.append({"id": ex_id, "detail": "Übung nicht gefunden"})
continue
rowd = r2d(row)
owner = rowd.get("created_by")
if owner is not None:
owner = int(owner)
if owner != profile_id and not is_platform_admin(role):
failed.append(
{
"id": ex_id,
"detail": "Keine Berechtigung (nur Ersteller oder Plattform-Admin)",
}
)
continue
ex_vis = (rowd.get("visibility") or "private").strip().lower()
ex_cid_raw = rowd.get("club_id")
ex_cid = int(ex_cid_raw) if ex_cid_raw is not None else None
next_vis = ex_vis
if patch_visibility:
next_vis = str(body.visibility).strip().lower()
next_club = ex_cid
if patch_visibility and body.club_id is not None:
next_club = int(body.club_id)
if patch_visibility:
if next_vis == "club":
if next_club is None:
next_club = tenant.effective_club_id
if next_club is None:
failed.append(
{
"id": ex_id,
"detail": "Vereins-Übung: club_id angeben oder aktiven Verein wählen (X-Active-Club-Id).",
}
)
continue
gov_club = next_club if next_vis == "club" else None
try:
assert_valid_governance_visibility(cur, profile_id, role, next_vis, gov_club)
except HTTPException as he:
failed.append({"id": ex_id, "detail": _fail_msg(he)})
continue
sets: List[str] = []
vals: List[Any] = []
if patch_visibility:
sets.extend(["visibility = %s", "club_id = %s"])
cid_out = next_club if next_vis == "club" else None
vals.extend([next_vis, cid_out])
if patch_status:
sets.append("status = %s")
vals.append(status_val)
sets.append("updated_at = NOW()")
vals.append(ex_id)
cur.execute(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
updated.append(ex_id)
conn.commit()
return {
"updated": updated,
"failed": failed,
"updated_count": len(updated),
"failed_count": len(failed),
}
@router.get("/exercises")
def list_exercises(
focus_area_ids: list[int] = Query(default=[], description="ODER: mind. einer dieser Fokusbereiche"),

View File

@ -1,6 +1,6 @@
# Shinkan Jinkendo Version Information
APP_VERSION = "0.8.28"
APP_VERSION = "0.8.29"
BUILD_DATE = "2026-05-05"
DB_SCHEMA_VERSION = "20260505041"
@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.6.4", # Detail-Lesen über library_content_visible_to_profile
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
"training_units": "0.1.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile
@ -27,6 +27,14 @@ MODULE_VERSIONS = {
}
CHANGELOG = [
{
"version": "0.8.29",
"date": "2026-05-05",
"changes": [
"Übungen: PATCH /api/exercises/bulk-metadata (bis 500 IDs) — Sichtbarkeit und/oder Status; Ersteller oder Plattform-Admin; Governance wie Einzel-PUT",
"Übungsliste: Mehrfachauswahl, Alle auf dieser Seite, Dialog Massenänderung (Verein/offiziell nur Admins)",
],
},
{
"version": "0.8.28",
"date": "2026-05-05",

View File

@ -1,11 +1,13 @@
import React, { useState, useEffect, useMemo, useCallback } from 'react'
import { Link } from 'react-router-dom'
import api from '../utils/api'
import { useAuth } from '../context/AuthContext'
import { SKILL_LEVEL_OPTIONS } from '../constants/skillLevels'
import MultiSelectCombo from '../components/MultiSelectCombo'
import ExerciseProgressionGraphPanel from '../components/ExerciseProgressionGraphPanel'
const PAGE_SIZE = 100
const BULK_MAX_IDS = 500
const LEVEL_FILTER_OPTS = SKILL_LEVEL_OPTIONS.filter((o) => o.level != null)
const INITIAL_FILTERS = {
@ -26,6 +28,9 @@ function levelOptionShort(levelStr) {
}
function ExercisesListPage() {
const { user } = useAuth()
const isPlatformAdmin = user?.role === 'admin' || user?.role === 'superadmin'
const [exercises, setExercises] = useState([])
const [catalogs, setCatalogs] = useState({
focusAreas: [],
@ -47,6 +52,14 @@ function ExercisesListPage() {
const [filterModalOpen, setFilterModalOpen] = useState(false)
const [pageTab, setPageTab] = useState('list')
const [selectedIds, setSelectedIds] = useState(() => new Set())
const [bulkModalOpen, setBulkModalOpen] = useState(false)
const [bulkVisibility, setBulkVisibility] = useState('')
const [bulkStatus, setBulkStatus] = useState('')
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
return () => clearTimeout(t)
@ -254,6 +267,67 @@ function ExercisesListPage() {
return q
}, [filters, debouncedSearch, debouncedAiSearch])
useEffect(() => {
setSelectedIds(new Set())
}, [queryBase])
const clubNameById = useMemo(() => {
const m = {}
for (const c of user?.clubs || []) {
if (c?.id != null) m[Number(c.id)] = c.name || `#${c.id}`
}
return m
}, [user?.clubs])
const effectiveClubId =
user?.effective_club_id != null && user.effective_club_id !== ''
? Number(user.effective_club_id)
: user?.active_club_id != null && user.active_club_id !== ''
? Number(user.active_club_id)
: null
const toggleSelect = useCallback((id) => {
setSelectedIds((prev) => {
const n = new Set(prev)
const nid = Number(id)
if (Number.isNaN(nid)) return prev
if (n.has(nid)) n.delete(nid)
else n.add(nid)
return n
})
}, [])
const clearSelection = useCallback(() => setSelectedIds(new Set()), [])
const toggleSelectAllPage = useCallback(() => {
setSelectedIds((prev) => {
const n = new Set(prev)
const allSel =
exercises.length > 0 && exercises.every((e) => n.has(Number(e.id)))
if (allSel) {
exercises.forEach((e) => n.delete(Number(e.id)))
} else {
exercises.forEach((e) => n.add(Number(e.id)))
}
return n
})
}, [exercises])
const allOnPageSelected = useMemo(
() => exercises.length > 0 && exercises.every((e) => selectedIds.has(Number(e.id))),
[exercises, selectedIds]
)
const bulkVisibilityOptions = useMemo(() => {
const base = [
{ id: '', label: '— nicht ändern —' },
{ id: 'private', label: 'Privat' },
{ id: 'club', label: 'Verein' },
]
if (isPlatformAdmin) base.push({ id: 'official', label: 'Offiziell (global)' })
return base
}, [isPlatformAdmin])
useEffect(() => {
let cancelled = false
;(async () => {
@ -342,6 +416,85 @@ function ExercisesListPage() {
const resetAllFilters = useCallback(() => setFilters({ ...INITIAL_FILTERS }), [])
const openBulkModal = () => {
setBulkVisibility('')
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
if (!bulkVisibility && !bulkStatus) {
alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
if (ids.length === 0) {
alert('Keine Übungen ausgewählt.')
return
}
if (ids.length > BULK_MAX_IDS) {
alert(`Maximal ${BULK_MAX_IDS} Übungen pro Vorgang. Bitte Auswahl oder mehrere Durchläufe verwenden.`)
return
}
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
else if (bulkClubSelect && /^\d+$/.test(String(bulkClubSelect))) {
payload.club_id = Number(bulkClubSelect)
}
}
setBulkSubmitting(true)
try {
const res = await api.bulkPatchExercisesMetadata(payload)
const updatedSet = new Set((res.updated || []).map((x) => Number(x)))
let resolvedClubId = null
if (bulkVisibility === 'club') {
if (payload.club_id != null) resolvedClubId = payload.club_id
else if (effectiveClubId != null && !Number.isNaN(effectiveClubId)) resolvedClubId = effectiveClubId
}
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
const next = { ...e }
if (bulkVisibility) {
next.visibility = bulkVisibility
next.club_id = bulkVisibility === 'club' ? resolvedClubId : null
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
return next
})
)
let msg = `${res.updated_count ?? updatedSet.size} Übung(en) aktualisiert.`
if (res.failed_count) msg += `\n${res.failed_count} nicht geändert (siehe Details).`
if (Array.isArray(res.failed) && res.failed.length) {
msg +=
'\n\n' +
res.failed
.slice(0, 12)
.map((f) => `#${f.id}: ${f.detail}`)
.join('\n')
if (res.failed.length > 12) msg += '\n…'
}
alert(msg)
setBulkModalOpen(false)
clearSelection()
} catch (err) {
alert('Massenänderung fehlgeschlagen: ' + (err.message || String(err)))
} finally {
setBulkSubmitting(false)
}
}
if (!catalogsReady && pageTab === 'list') {
return (
<div style={{ padding: '2rem', textAlign: 'center' }}>
@ -470,9 +623,43 @@ function ExercisesListPage() {
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: '10px', marginBottom: 0 }}>
Trefferliste aktualisiert sich kurz nach Eingabe. Titel der aktuellen Liste erscheinen als Vorschläge (Pfeil im
Feld). Fachliche Filter über Filter zwischen Feldern UND, Auswahl mehrerer Werte je Feld mit ODER.
{exercises.length > 0 ? (
<>
{' '}
<button type="button" className="btn btn-secondary" style={{ marginLeft: '6px' }} onClick={toggleSelectAllPage}>
{allOnPageSelected ? 'Auswahl auf dieser Seite aufheben' : 'Alle auf dieser Seite auswählen'}
</button>
</>
) : null}
</p>
</div>
{selectedIds.size > 0 ? (
<div
className="card"
style={{
marginBottom: '12px',
display: 'flex',
flexWrap: 'wrap',
alignItems: 'center',
gap: '10px',
}}
>
<strong>{selectedIds.size} ausgewählt</strong>
<button type="button" className="btn btn-secondary" onClick={clearSelection}>
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
Sichtbarkeit / Status ändern
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Bis zu {BULK_MAX_IDS} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
<code>X-Active-Club-Id</code>
).
</span>
</div>
) : null}
{filterModalOpen && (
<div
className="admin-modal-backdrop"
@ -637,6 +824,118 @@ function ExercisesListPage() {
</div>
)}
{bulkModalOpen ? (
<div
className="admin-modal-backdrop"
role="presentation"
onClick={(e) => {
if (e.target === e.currentTarget) setBulkModalOpen(false)
}}
>
<div
className="admin-modal-sheet exercise-filter-modal"
role="dialog"
aria-modal="true"
aria-labelledby="exercise-bulk-modal-title"
onClick={(e) => e.stopPropagation()}
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung: Sichtbarkeit / Status
</h3>
<button
type="button"
className="btn btn-secondary admin-modal-sheet__close"
onClick={() => setBulkModalOpen(false)}
>
Schließen
</button>
</div>
<div className="admin-modal-sheet__body exercise-filter-modal__scroll">
<p style={{ fontSize: '13px', color: 'var(--text2)', marginTop: 0 }}>
Es werden <strong>{selectedIds.size}</strong> Übung(en) aus der aktuellen Auswahl bearbeitet. Pro Durchlauf
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
className="form-input"
value={bulkVisibility}
onChange={(e) => setBulkVisibility(e.target.value)}
>
{bulkVisibilityOptions.map((o) => (
<option key={o.id === '' ? '_unchanged' : o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
{bulkVisibility === 'club' ? (
<div className="form-row">
<label className="form-label">Verein zuordnen</label>
<select
className="form-input"
value={bulkClubSelect}
onChange={(e) => setBulkClubSelect(e.target.value)}
>
<option value="">Aktiver Verein (Vereins-Umschalter / Header)</option>
{(user?.clubs || []).map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name || `#${c.id}`}
</option>
))}
</select>
{isPlatformAdmin ? (
<>
<label className="form-label" style={{ marginTop: '10px' }}>
Oder Vereins-ID (Plattform-Admin)
</label>
<input
type="number"
min={1}
className="form-input"
placeholder="Leer = wie Dropdown / aktiver Verein"
value={bulkClubManual}
onChange={(e) => setBulkClubManual(e.target.value)}
/>
</>
) : null}
</div>
) : null}
<div className="form-row">
<label className="form-label">Status</label>
<select
className="form-input"
value={bulkStatus}
onChange={(e) => setBulkStatus(e.target.value)}
>
<option value=""> nicht ändern </option>
{statusOptions.map((o) => (
<option key={o.id} value={o.id}>
{o.label}
</option>
))}
</select>
</div>
</div>
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
<button
type="button"
className="btn"
disabled={bulkSubmitting}
onClick={() => setBulkModalOpen(false)}
>
Abbrechen
</button>
<button type="button" className="btn btn-primary" disabled={bulkSubmitting} onClick={handleBulkSubmit}>
{bulkSubmitting ? 'Speichern…' : 'Anwenden'}
</button>
</div>
</div>
</div>
) : null}
{listFetching && exercises.length === 0 ? (
<div className="card" style={{ textAlign: 'center', padding: '2rem' }}>
<div className="spinner"></div>
@ -666,7 +965,15 @@ function ExercisesListPage() {
>
{exercises.map((exercise) => (
<div key={exercise.id} className="card exercise-card">
<div className="exercise-card__body">
<div style={{ display: 'flex', gap: '10px', alignItems: 'flex-start' }}>
<input
type="checkbox"
checked={selectedIds.has(Number(exercise.id))}
onChange={() => toggleSelect(exercise.id)}
aria-label={`${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`}
style={{ marginTop: '4px', flexShrink: 0 }}
/>
<div className="exercise-card__body" style={{ flex: 1, minWidth: 0 }}>
<h3 style={{ marginBottom: '8px', fontSize: '1.05rem', lineHeight: 1.3 }}>
<Link
to={`/exercises/${exercise.id}`}
@ -689,6 +996,7 @@ function ExercisesListPage() {
: exercise.summary}
</p>
)}
</div>
</div>
<div className="exercise-card__actions">
<Link to={`/exercises/${exercise.id}`} className="btn btn-secondary">

View File

@ -508,6 +508,14 @@ export async function updateExercise(id, data) {
})
}
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',
body: JSON.stringify(data),
})
}
export async function deleteExercise(id) {
return request(`/api/exercises/${id}`, { method: 'DELETE' })
}
@ -1156,6 +1164,7 @@ export const api = {
getExercise,
createExercise,
updateExercise,
bulkPatchExercisesMetadata,
deleteExercise,
createExerciseVariant,
updateExerciseVariant,

View File

@ -1,13 +1,13 @@
// Shinkan Jinkendo Frontend Version
export const APP_VERSION = "0.8.28"
export const APP_VERSION = "0.8.29"
export const BUILD_DATE = "2026-05-05"
export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
ExercisesPage: "1.1.0", // Updated: Katalog-Integration
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.3.1",