diff --git a/backend/routers/exercises.py b/backend/routers/exercises.py index e5b0e2b..fc4cef0 100644 --- a/backend/routers/exercises.py +++ b/backend/routers/exercises.py @@ -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() diff --git a/backend/version.py b/backend/version.py index ca0e8a0..5902c86 100644 --- a/backend/version.py +++ b/backend/version.py @@ -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 diff --git a/frontend/src/pages/ExercisesListPage.jsx b/frontend/src/pages/ExercisesListPage.jsx index 38603ec..6cacfd9 100644 --- a/frontend/src/pages/ExercisesListPage.jsx +++ b/frontend/src/pages/ExercisesListPage.jsx @@ -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 Bis zu {BULK_MAX_IDS} pro Anfrage. Für „Verein“ ohne Auswahl: aktiver Vereinskontext ( @@ -841,7 +889,7 @@ function ExercisesListPage() { >

- Massenänderung: Sichtbarkeit / Status + Massenänderung

+