fix(dcim): Add bulk edit support for Module Bay Types

Adds `add_module_bay_types` and `remove_module_bay_types` fields to
ModuleBayTemplate bulk edit form with fieldsets. Implements per-object
delta operations in post_save_operations to preserve existing type
assignments while adding/removing specified types.

Fixes #22961
This commit is contained in:
Martin Hauser 2026-08-19 15:53:41 +02:00 committed by Jeremy Stretch
parent 05865a5e5c
commit cff012ac9c
3 changed files with 155 additions and 0 deletions

View File

@ -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')

View File

@ -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

View File

@ -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):