diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 998f9943d..c117207c1 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1601,7 +1601,21 @@ class ModuleBayTemplateBulkEditForm(ComponentTemplateBulkEditForm): required=False, widget=BulkEditNullBooleanSelect, ) + add_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Add bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + remove_module_bay_types = DynamicModelMultipleChoiceField( + label=_('Remove bay types'), + queryset=ModuleBayType.objects.all(), + required=False, + ) + fieldsets = ( + FieldSet('label', 'enabled', 'description'), + FieldSet('add_module_bay_types', 'remove_module_bay_types', name=_('Bay Types')), + ) nullable_fields = ('label', 'position', 'description') diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index b011ed9d4..acce102a0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1853,6 +1853,47 @@ module-bays: self.assertGreater(len(rows), 1) self.assertEqual(len(rows) - 1, ModuleType.objects.count()) + @tag('regression') # Issue #22961 + def test_bulk_edit_module_bay_types(self): + """ + Bulk edit adds and removes bay types per object, leaving each object's other assignments intact. + """ + self.add_permissions( + 'dcim.view_moduletype', + 'dcim.change_moduletype', + 'dcim.view_modulebaytype', + ) + + kept_types = ( + ModuleBayType.objects.create(name='Kept Type 1', slug='kept-type-1'), + ModuleBayType.objects.create(name='Kept Type 2', slug='kept-type-2'), + ) + removed_type = ModuleBayType.objects.create(name='Removed Type', slug='removed-type') + added_type = ModuleBayType.objects.create(name='Added Type', slug='added-type') + + # Differing starting sets prove a per-object delta rather than a wholesale replacement + module_types = list( + ModuleType.objects.filter(model__in=('Module Type 2', 'Module Type 3')).order_by('pk') + ) + for module_type, kept_type in zip(module_types, kept_types): + module_type.module_bay_types.set((kept_type, removed_type)) + + pk_list = [module_type.pk for module_type in module_types] + + response = self.client.post(self._get_url('bulk_edit'), post_data({ + 'pk': pk_list, + 'add_module_bay_types': [added_type.pk], + 'remove_module_bay_types': [removed_type.pk], + '_apply': True, + })) + self.assertHttpStatus(response, 302) + + for module_type, kept_type in zip(module_types, kept_types): + self.assertEqual( + set(module_type.module_bay_types.values_list('pk', flat=True)), + {kept_type.pk, added_type.pk}, + ) + class ModuleTypeProfileTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = ModuleTypeProfile @@ -2274,6 +2315,56 @@ class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas 'description': 'Foo bar', } + @tag('regression') # Issue #22961 + def test_bulk_edit_module_bay_types(self): + """ + Bulk edit adds and removes bay types per object, leaving each object's other assignments intact. + """ + self.add_permissions( + 'dcim.view_modulebaytemplate', + 'dcim.change_modulebaytemplate', + 'dcim.view_modulebaytype', + ) + + kept_types = ( + ModuleBayType.objects.create(name='Kept Type 1', slug='kept-type-1'), + ModuleBayType.objects.create(name='Kept Type 2', slug='kept-type-2'), + ) + removed_type = ModuleBayType.objects.create(name='Removed Type', slug='removed-type') + added_type = ModuleBayType.objects.create(name='Added Type', slug='added-type') + + # Differing starting sets prove a per-object delta rather than a wholesale replacement + module_bay_templates = list(ModuleBayTemplate.objects.order_by('pk')[:2]) + for module_bay_template, kept_type in zip(module_bay_templates, kept_types): + module_bay_template.module_bay_types.set((kept_type, removed_type)) + + pk_list = [module_bay_template.pk for module_bay_template in module_bay_templates] + + # The reported symptom: both controls must appear on the bulk-edit form + response = self.client.post(self._get_url('bulk_edit'), {'pk': pk_list}) + self.assertHttpStatus(response, 200) + self.assertContains(response, 'Add bay types') + self.assertContains(response, 'Remove bay types') + + # Declaring fieldsets switches the template to its grouped branch, which must keep + # annotating the nullable fields + self.assertContains(response, 'id="nullify_id_label"') + self.assertContains(response, 'id="nullify_id_description"') + + response = self.client.post(self._get_url('bulk_edit'), post_data({ + 'pk': pk_list, + 'add_module_bay_types': [added_type.pk], + 'remove_module_bay_types': [removed_type.pk], + '_apply': True, + })) + self.assertHttpStatus(response, 302) + + for module_bay_template, kept_type in zip(module_bay_templates, kept_types): + self.assertEqual( + set(module_bay_template.module_bay_types.values_list('pk', flat=True)), + {kept_type.pk, added_type.pk}, + ) + class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): model = DeviceBayTemplate @@ -4177,6 +4268,45 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase): self.assertEqual(initial_count + 8, self._get_queryset().count()) + @tag('regression') # Issue #22961 + def test_bulk_edit_module_bay_types(self): + """ + Bulk edit adds and removes bay types per object, leaving each object's other assignments intact. + """ + self.add_permissions( + 'dcim.view_modulebay', + 'dcim.change_modulebay', + 'dcim.view_modulebaytype', + ) + + kept_types = ( + ModuleBayType.objects.create(name='Kept Type 1', slug='kept-type-1'), + ModuleBayType.objects.create(name='Kept Type 2', slug='kept-type-2'), + ) + removed_type = ModuleBayType.objects.create(name='Removed Type', slug='removed-type') + added_type = ModuleBayType.objects.create(name='Added Type', slug='added-type') + + # Differing starting sets prove a per-object delta rather than a wholesale replacement + module_bays = list(ModuleBay.objects.order_by('pk')[:2]) + for module_bay, kept_type in zip(module_bays, kept_types): + module_bay.module_bay_types.set((kept_type, removed_type)) + + pk_list = [module_bay.pk for module_bay in module_bays] + + response = self.client.post(self._get_url('bulk_edit'), post_data({ + 'pk': pk_list, + 'add_module_bay_types': [added_type.pk], + 'remove_module_bay_types': [removed_type.pk], + '_apply': True, + })) + self.assertHttpStatus(response, 302) + + for module_bay, kept_type in zip(module_bays, kept_types): + self.assertEqual( + set(module_bay.module_bay_types.values_list('pk', flat=True)), + {kept_type.pk, added_type.pk}, + ) + class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase): model = DeviceBay diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 85755a7c4..81a8069f1 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2626,6 +2626,17 @@ class ModuleBayTemplateBulkEditView(generic.BulkEditView): table = tables.ModuleBayTemplateTable form = forms.ModuleBayTemplateBulkEditForm + def post_save_operations(self, form, obj): + # Unlike the ModuleType and ModuleBay editors, no compatibility warning: a template + # has no installed module to invalidate. + super().post_save_operations(form, obj) + add = form.cleaned_data.get('add_module_bay_types') + remove = form.cleaned_data.get('remove_module_bay_types') + if add: + obj.module_bay_types.add(*add) + if remove: + obj.module_bay_types.remove(*remove) + @register_model_view(ModuleBayTemplate, 'bulk_rename', path='rename', detail=False) class ModuleBayTemplateBulkRenameView(generic.BulkRenameView):