From dc310b38eba50f023633b0f145eb572b02f88c4e Mon Sep 17 00:00:00 2001 From: Lars Date: Tue, 5 May 2026 22:18:04 +0200 Subject: [PATCH] feat: implement bulk metadata update for exercises - 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. --- .../working/ACCESS_LAYER_ENDPOINT_AUDIT.md | 3 +- backend/routers/exercises.py | 142 +++++++- backend/version.py | 12 +- frontend/src/pages/ExercisesListPage.jsx | 310 +++++++++++++++++- frontend/src/utils/api.js | 9 + frontend/src/version.js | 4 +- 6 files changed, 473 insertions(+), 7 deletions(-) diff --git a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md index bfc97ec..87ac058 100644 --- a/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md +++ b/.claude/docs/working/ACCESS_LAYER_ENDPOINT_AUDIT.md @@ -9,7 +9,8 @@ Fortlaufend gemäß `ACCESS_LAYER_AND_GOVERNANCE_PLAN.md` Stufe A–C. | 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 | diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index 3151141..e5b0e2b 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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"), diff --git a/backend/version.py b/backend/version.py index ceba5b4..82613d5 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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", diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index dae8100..38603ec 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -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 (
@@ -470,9 +623,43 @@ function ExercisesListPage() {

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 ? ( + <> + {' '} + + + ) : null}

+ {selectedIds.size > 0 ? ( +
+ {selectedIds.size} ausgewählt + + + + Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext ( + X-Active-Club-Id + ). + +
+ ) : null} + {filterModalOpen && (
)} + {bulkModalOpen ? ( +
{ + if (e.target === e.currentTarget) setBulkModalOpen(false) + }} + > +
e.stopPropagation()} + > +
+

+ Massenänderung: Sichtbarkeit / Status +

+ +
+
+

+ Es werden {selectedIds.size} Ü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). +

+
+ + +
+ {bulkVisibility === 'club' ? ( +
+ + + {isPlatformAdmin ? ( + <> + + setBulkClubManual(e.target.value)} + /> + + ) : null} +
+ ) : null} +
+ + +
+
+
+ + +
+
+
+ ) : null} + {listFetching && exercises.length === 0 ? (
@@ -666,7 +965,15 @@ function ExercisesListPage() { > {exercises.map((exercise) => (
-
+
+ toggleSelect(exercise.id)} + aria-label={`„${(exercise.title || 'Übung').replace(/"/g, '')}“ auswählen`} + style={{ marginTop: '4px', flexShrink: 0 }} + /> +

)} +

diff --git a/frontend/src/utils/api.js b/frontend/src/utils/api.js index 09b54d8..db1b68a 100644 --- a/frontend/src/utils/api.js +++ b/frontend/src/utils/api.js @@ -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, diff --git a/frontend/src/version.js b/frontend/src/version.js index d2d2e11..b6e6200 100644 --- a/frontend/src/version.js +++ b/frontend/src/version.js @@ -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",