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

- Bumped the version of exercises to 2.8.0, reflecting new features in the bulk metadata patch.
- Enhanced the ExerciseBulkMetadataPatch model to include focus area, style direction, training type, and target group IDs.
- Updated the bulk patch endpoint to support replacing catalog associations for exercises.
- Improved the ExercisesListPage to handle new relation fields and updated UI for bulk operations.
- Adjusted API documentation to reflect changes in the bulk patch functionality.
This commit is contained in:
Lars 2026-05-06 11:02:46 +02:00
parent 35a3f6e18d
commit 8b86021293
5 changed files with 272 additions and 14 deletions

View File

@ -215,20 +215,35 @@ class ExerciseVariantsReorder(BaseModel):
_VALID_EXERCISE_STATUS_BULK = frozenset({"draft", "in_review", "approved", "archived"})
_MAX_BULK_METADATA_IDS = 500
_MAX_BULK_RELATION_IDS_PER_KIND = 80
class ExerciseBulkMetadataPatch(BaseModel):
"""Massenänderung von Sichtbarkeit und/oder Status (z. B. Private → Verein)."""
"""Massenänderung: Sichtbarkeit/Status und/oder Zuordnungen (Kataloge)."""
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)
focus_area_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
style_direction_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
training_type_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
target_group_ids: Optional[list[int]] = Field(default=None, max_length=_MAX_BULK_RELATION_IDS_PER_KIND)
@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")
def at_least_one_patch_field(self):
if (
self.visibility is None
and self.status is None
and self.focus_area_ids is None
and self.style_direction_ids is None
and self.training_type_ids is None
and self.target_group_ids is None
):
raise ValueError(
"Mindestens eines der Felder visibility, status, focus_area_ids, style_direction_ids, "
"training_type_ids oder target_group_ids angeben"
)
return self
@ -456,7 +471,14 @@ def enrich_exercise_detail(exercise_id: int, cur) -> dict:
return exercise
def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
def assign_exercise_relations(
cur,
conn,
exercise_id: int,
data: dict,
*,
do_commit: bool = True,
):
"""
Weist M:N Relations für eine Übung zu.
Löscht alte Zuordnungen und legt neue an (REPLACE-Logik).
@ -532,13 +554,59 @@ def assign_exercise_relations(cur, conn, exercise_id: int, data: dict):
)
)
conn.commit()
if do_commit:
conn.commit()
# ============================================================================
# Endpoints
# ============================================================================
def _normalize_bulk_id_list(raw: Optional[list]) -> list[int]:
"""Positive IDs, Reihenfolge beibehalten, Duplikate entfernen."""
if not raw:
return []
seen: set[int] = set()
out: list[int] = []
for x in raw:
try:
xi = int(x)
except (TypeError, ValueError):
continue
if xi < 1 or xi in seen:
continue
seen.add(xi)
out.append(xi)
return out
def _assert_catalog_ids_exist(cur, kind: str, ids: list[int]) -> None:
if not ids:
return
table_by_kind = {
"focus_areas": "focus_areas",
"style_directions": "style_directions",
"training_types": "training_types",
"target_groups": "target_groups",
}
table = table_by_kind.get(kind)
if not table:
raise HTTPException(status_code=500, detail="Interner Fehler: unbekannter Katalog")
ph = ",".join(["%s"] * len(ids))
cur.execute(f"SELECT id FROM {table} WHERE id IN ({ph})", tuple(ids))
found = {
int(r["id"]) if isinstance(r, dict) else int(r[0])
for r in cur.fetchall()
}
missing = [i for i in ids if i not in found]
if missing:
raise HTTPException(
status_code=400,
detail=f"Unbekannte {kind}-IDs (Beispiele): {missing[:12]}",
)
def _merge_ids(multi: list[int], single: Optional[int]) -> list[int]:
"""Liste aus wiederholten Query-Parametern plus optional einem Legacy-Einzelfilter (ohne Duplikate)."""
seen: set[int] = set()
@ -577,7 +645,11 @@ def bulk_patch_exercises_metadata(
tenant: TenantContext = Depends(get_tenant_context),
):
"""
Ändert Sichtbarkeit und/oder Status für viele Übungen auf einmal.
Ändert Sichtbarkeit, Status und/oder Katalog-Zuordnungen für viele Übungen auf einmal (REPLACE je Kategorie).
Zuordnung: Sind z. B. focus_area_ids im Body gesetzt, werden die Fokusbereiche bei den bearbeiteten
Übungen vollständig durch diese Liste ersetzt (leeres Array entfernt alle).
Berechtigt: Ersteller der jeweiligen Übung oder Plattform-Admin (admin/superadmin).
Governance wie bei Einzel-PUT (official nur Plattform-Admin; club mit Mitgliedschaft bzw. Admin).
"""
@ -603,6 +675,33 @@ def bulk_patch_exercises_metadata(
patch_visibility = body.visibility is not None
patch_status = status_val is not None
patch_focus_areas = body.focus_area_ids is not None
fa_ids = _normalize_bulk_id_list(body.focus_area_ids or []) if patch_focus_areas else []
patch_style_dirs = body.style_direction_ids is not None
sd_ids = _normalize_bulk_id_list(body.style_direction_ids or []) if patch_style_dirs else []
patch_training_types = body.training_type_ids is not None
tt_ids = _normalize_bulk_id_list(body.training_type_ids or []) if patch_training_types else []
patch_target_groups = body.target_group_ids is not None
tg_ids = _normalize_bulk_id_list(body.target_group_ids or []) if patch_target_groups else []
relation_data: Dict[str, Any] = {}
if patch_focus_areas:
relation_data["focus_areas_multi"] = [
{"focus_area_id": i, "is_primary": idx == 0} for idx, i in enumerate(fa_ids)
]
if patch_style_dirs:
relation_data["training_styles_multi"] = [
{"training_style_id": i, "is_primary": idx == 0} for idx, i in enumerate(sd_ids)
]
if patch_training_types:
relation_data["training_types_multi"] = [
{"training_type_id": i, "is_primary": idx == 0} for idx, i in enumerate(tt_ids)
]
if patch_target_groups:
relation_data["target_groups_multi"] = [
{"target_group_id": i, "is_primary": idx == 0} for idx, i in enumerate(tg_ids)
]
updated: List[int] = []
failed: List[Dict[str, Any]] = []
@ -612,6 +711,16 @@ def bulk_patch_exercises_metadata(
with get_db() as conn:
cur = get_cursor(conn)
if patch_focus_areas:
_assert_catalog_ids_exist(cur, "focus_areas", fa_ids)
if patch_style_dirs:
_assert_catalog_ids_exist(cur, "style_directions", sd_ids)
if patch_training_types:
_assert_catalog_ids_exist(cur, "training_types", tt_ids)
if patch_target_groups:
_assert_catalog_ids_exist(cur, "target_groups", tg_ids)
for ex_id in unique_ids:
cur.execute(
"SELECT id, created_by, visibility, club_id FROM exercises WHERE id = %s",
@ -681,6 +790,8 @@ def bulk_patch_exercises_metadata(
f"UPDATE exercises SET {', '.join(sets)} WHERE id = %s",
tuple(vals),
)
if relation_data:
assign_exercise_relations(cur, conn, ex_id, relation_data, do_commit=False)
updated.append(ex_id)
conn.commit()

View File

@ -15,7 +15,7 @@ MODULE_VERSIONS = {
"groups": "0.1.0",
"skills": "0.1.0",
"methods": "0.1.0",
"exercises": "2.7.0", # PATCH /exercises/bulk-metadata — Massenänderung Sichtbarkeit/Status
"exercises": "2.8.0", # PATCH bulk-metadata: Sichtbarkeit/Status + Katalog-Zuordnungen (REPLACE je Feld)
"training_units": "0.2.0",
"training_programs": "0.1.0",
"planning": "0.8.1", # Vorlagen Leseprüfung library_content_visible_to_profile

View File

@ -59,6 +59,14 @@ function ExercisesListPage() {
const [bulkClubSelect, setBulkClubSelect] = useState('')
const [bulkClubManual, setBulkClubManual] = useState('')
const [bulkSubmitting, setBulkSubmitting] = useState(false)
const [bulkPatchFocusAreas, setBulkPatchFocusAreas] = useState(false)
const [bulkFocusAreaIds, setBulkFocusAreaIds] = useState([])
const [bulkPatchStyleDirections, setBulkPatchStyleDirections] = useState(false)
const [bulkStyleDirectionIds, setBulkStyleDirectionIds] = useState([])
const [bulkPatchTrainingTypes, setBulkPatchTrainingTypes] = useState(false)
const [bulkTrainingTypeIds, setBulkTrainingTypeIds] = useState([])
const [bulkPatchTargetGroups, setBulkPatchTargetGroups] = useState(false)
const [bulkTargetGroupIds, setBulkTargetGroupIds] = useState([])
useEffect(() => {
const t = setTimeout(() => setDebouncedSearch(searchInput.trim()), 400)
@ -421,12 +429,27 @@ function ExercisesListPage() {
setBulkStatus('')
setBulkClubSelect('')
setBulkClubManual('')
setBulkPatchFocusAreas(false)
setBulkFocusAreaIds([])
setBulkPatchStyleDirections(false)
setBulkStyleDirectionIds([])
setBulkPatchTrainingTypes(false)
setBulkTrainingTypeIds([])
setBulkPatchTargetGroups(false)
setBulkTargetGroupIds([])
setBulkModalOpen(true)
}
const handleBulkSubmit = async () => {
if (!bulkVisibility && !bulkStatus) {
alert('Bitte mindestens Sichtbarkeit oder Status wählen (nicht „nicht ändern“ bei beiden).')
const anyRelationPatch =
bulkPatchFocusAreas ||
bulkPatchStyleDirections ||
bulkPatchTrainingTypes ||
bulkPatchTargetGroups
if (!bulkVisibility && !bulkStatus && !anyRelationPatch) {
alert(
'Bitte mindestens eine Änderung wählen (Sichtbarkeit, Status oder eine der Zuordnungen mit gesetztem Häkchen „ersetzen“).'
)
return
}
const ids = Array.from(selectedIds).filter((x) => Number.isFinite(x) && x > 0)
@ -441,6 +464,18 @@ function ExercisesListPage() {
const payload = { exercise_ids: ids }
if (bulkVisibility) payload.visibility = bulkVisibility
if (bulkStatus) payload.status = bulkStatus
if (bulkPatchFocusAreas) {
payload.focus_area_ids = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchStyleDirections) {
payload.style_direction_ids = bulkStyleDirectionIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTrainingTypes) {
payload.training_type_ids = bulkTrainingTypeIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkPatchTargetGroups) {
payload.target_group_ids = bulkTargetGroupIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
}
if (bulkVisibility === 'club') {
const manual = String(bulkClubManual || '').trim()
if (manual && /^\d+$/.test(manual)) payload.club_id = Number(manual)
@ -460,6 +495,15 @@ function ExercisesListPage() {
const clubLabel =
resolvedClubId != null ? clubNameById[resolvedClubId] || `Verein #${resolvedClubId}` : null
let nextPrimaryFocusName = null
if (bulkPatchFocusAreas) {
const faNums = bulkFocusAreaIds.map((x) => Number(x)).filter((n) => Number.isFinite(n) && n > 0)
if (faNums.length > 0) {
const opt = focusOptions.find((o) => Number(o.id) === Number(faNums[0]))
nextPrimaryFocusName = String(opt?.label ?? '').trim() || String(faNums[0])
}
}
setExercises((prev) =>
prev.map((e) => {
if (!updatedSet.has(Number(e.id))) return e
@ -470,6 +514,10 @@ function ExercisesListPage() {
next.club_name = bulkVisibility === 'club' ? clubLabel : null
}
if (bulkStatus) next.status = bulkStatus
if (bulkPatchFocusAreas) {
if (nextPrimaryFocusName == null) delete next.focus_area
else next.focus_area = nextPrimaryFocusName
}
return next
})
)
@ -650,7 +698,7 @@ function ExercisesListPage() {
Auswahl aufheben
</button>
<button type="button" className="btn btn-primary" onClick={openBulkModal}>
Sichtbarkeit / Status ändern
Massenänderung
</button>
<span style={{ fontSize: '12px', color: 'var(--text3)' }}>
Bis zu {BULK_MAX_IDS} pro Anfrage. Für Verein ohne Auswahl: aktiver Vereinskontext (
@ -841,7 +889,7 @@ function ExercisesListPage() {
>
<div className="admin-modal-sheet__header">
<h3 id="exercise-bulk-modal-title" className="admin-modal-sheet__title">
Massenänderung: Sichtbarkeit / Status
Massenänderung
</h3>
<button
type="button"
@ -857,6 +905,11 @@ function ExercisesListPage() {
höchstens {BULK_MAX_IDS}. Ohne Berechtigung bleiben Einzelübungen unverändert (siehe Hinweis nach dem
Speichern).
</p>
<p style={{ fontSize: '12px', color: 'var(--text3)', marginTop: 0, marginBottom: '14px' }}>
Unter Zuordnung ersetzen: die gewählte Liste ersetzt die bisherige Zuordnung bei allen betroffenen
Übungen vollständig (leere Auswahl = alle Zuordnungen dieser Kategorie entfernen). Die erste Auswahl gilt
als Primärzuordnung.
</p>
<div className="form-row">
<label className="form-label">Sichtbarkeit</label>
<select
@ -918,6 +971,100 @@ function ExercisesListPage() {
))}
</select>
</div>
<section className="exercise-filter-section" style={{ marginTop: '8px', paddingTop: '12px' }}>
<h4 className="exercise-filter-section-title">Zuordnung (optional)</h4>
<div className="exercise-filters-modal-grid">
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchFocusAreas}
onChange={(e) => {
const on = e.target.checked
setBulkPatchFocusAreas(on)
if (!on) setBulkFocusAreaIds([])
}}
/>
Fokusbereiche ersetzen
</label>
{bulkPatchFocusAreas ? (
<MultiSelectCombo
value={bulkFocusAreaIds}
onChange={setBulkFocusAreaIds}
options={focusOptions}
placeholder="Fokusbereiche wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchStyleDirections}
onChange={(e) => {
const on = e.target.checked
setBulkPatchStyleDirections(on)
if (!on) setBulkStyleDirectionIds([])
}}
/>
Stilrichtungen ersetzen
</label>
{bulkPatchStyleDirections ? (
<MultiSelectCombo
value={bulkStyleDirectionIds}
onChange={setBulkStyleDirectionIds}
options={styleOptions}
placeholder="Stilrichtungen wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTrainingTypes}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTrainingTypes(on)
if (!on) setBulkTrainingTypeIds([])
}}
/>
Trainingsstile ersetzen
</label>
{bulkPatchTrainingTypes ? (
<MultiSelectCombo
value={bulkTrainingTypeIds}
onChange={setBulkTrainingTypeIds}
options={trainingTypeOptions}
placeholder="Trainingsstile wählen …"
/>
) : null}
</div>
<div>
<label className="form-label" style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<input
type="checkbox"
checked={bulkPatchTargetGroups}
onChange={(e) => {
const on = e.target.checked
setBulkPatchTargetGroups(on)
if (!on) setBulkTargetGroupIds([])
}}
/>
Zielgruppen ersetzen
</label>
{bulkPatchTargetGroups ? (
<MultiSelectCombo
value={bulkTargetGroupIds}
onChange={setBulkTargetGroupIds}
options={targetGroupOptions}
placeholder="Zielgruppen wählen …"
/>
) : null}
</div>
</div>
</section>
</div>
<div className="exercise-filter-modal__footer" style={{ justifyContent: 'flex-end' }}>
<button

View File

@ -508,7 +508,7 @@ export async function updateExercise(id, data) {
})
}
/** Massenänderung Sichtbarkeit / Status (`PATCH /api/exercises/bulk-metadata`). */
/** Massenänderung Übungen: Sichtbarkeit, Status, Katalog-Zuordnungen (`PATCH /api/exercises/bulk-metadata`). */
export async function bulkPatchExercisesMetadata(data) {
return request('/api/exercises/bulk-metadata', {
method: 'PATCH',

View File

@ -7,7 +7,7 @@ export const PAGE_VERSIONS = {
LoginPage: "1.0.0",
Dashboard: "1.0.0",
AccountSettingsPage: "1.0.0",
ExercisesPage: "1.2.0", // Massenänderung Sichtbarkeit/Status auf der Liste
ExercisesPage: "1.3.0", // Massenänderung inkl. Fokusbereiche, Stilrichtungen, Trainingsstile, Zielgruppen
ClubsPage: "1.1.0",
SkillsPage: "1.0.0",
TrainingPlanningPage: "1.4.0",