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
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:
parent
35a3f6e18d
commit
8b86021293
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in New Issue
Block a user