From 157a30ecd752b32c2f941edadb0f27cba6bb4743 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 13 Aug 2026 07:50:59 -0400 Subject: [PATCH] #19731: Support module_bay_types in device/module type YAML import and export Follow-up QA for the ModuleBayType feature added in #22648. ModuleBayTemplate.to_yaml() omitted module_bay_types, and ModuleBayTemplateImportForm (used by the DeviceType/ModuleType YAML "Import Components" flow) didn't expose the field either, so bay-type constraints could never be defined as part of a device type's YAML definition -- only assigned by hand, one bay at a time, after import. Add module_bay_types (by name) to the import form, scoped to the parent device/module type's manufacturer (or global types) via clean_device_type/ clean_module_type, mirroring the existing scoping pattern used elsewhere in this form for power_port/cooling_intake. Add it to to_yaml()'s output symmetrically. --- netbox/dcim/forms/object_import.py | 27 ++++++++++++++++++- .../dcim/models/device_component_templates.py | 1 + netbox/dcim/tests/test_models.py | 13 +++++++++ netbox/dcim/tests/test_views.py | 8 ++++++ 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 3466d6243..2acc00824 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -1,4 +1,5 @@ from django import forms +from django.db.models import Q from django.utils.translation import gettext_lazy as _ from dcim.choices import InterfacePoEModeChoices, InterfacePoETypeChoices, InterfaceTypeChoices, PortTypeChoices @@ -213,13 +214,37 @@ class PortTemplateMappingImportForm(forms.ModelForm): class ModuleBayTemplateImportForm(forms.ModelForm): + module_bay_types = forms.ModelMultipleChoiceField( + label=_('Module bay types'), + queryset=ModuleBayType.objects.all(), + to_field_name='name', + required=False, + ) class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'module_type', 'name', 'label', 'position', 'description', + 'device_type', 'module_type', 'name', 'label', 'position', 'description', 'module_bay_types', ] + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + module_bay_types = self.fields['module_bay_types'] + module_bay_types.queryset = module_bay_types.queryset.filter( + Q(manufacturer__isnull=True) | Q(manufacturer=device_type.manufacturer) + ) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + module_bay_types = self.fields['module_bay_types'] + module_bay_types.queryset = module_bay_types.queryset.filter( + Q(manufacturer__isnull=True) | Q(manufacturer=module_type.manufacturer) + ) + + return module_type + class DeviceBayTemplateImportForm(forms.ModelForm): diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index dfcdd8159..97ced7d9b 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -988,6 +988,7 @@ class ModuleBayTemplate(ModularComponentTemplateModel): 'position': self.position, 'enabled': self.enabled, 'description': self.description, + 'module_bay_types': [t.name for t in self.module_bay_types.all()], } diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index e7f9de608..cd95fc66e 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -182,6 +182,19 @@ class ModuleTypeTestCase(TestCase): module_type.refresh_from_db() self.assertEqual(module_type.interface_template_count, 1) + def test_module_bay_template_to_yaml_includes_module_bay_types(self): + """ + ModuleBayTemplate.to_yaml() should export its assigned module bay types by name. + """ + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + bay_type = ModuleBayType.objects.create(name='SFP28', slug='sfp28') + module_bay_template = ModuleBayTemplate.objects.create(module_type=module_type, name='Module Bay 1') + module_bay_template.module_bay_types.set([bay_type]) + + data = module_bay_template.to_yaml() + self.assertEqual(data['module_bay_types'], ['SFP28']) + def test_attributes(self): """ ModuleType.attributes should normalize iterable values into strings for presentation. diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c474f1ae8..60476dfb3 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -998,6 +998,8 @@ port-mappings: rear_port: Rear Port 3 module-bays: - name: Module Bay 1 + module_bay_types: + - SFP28 - name: Module Bay 2 - name: Module Bay 3 device-bays: @@ -1018,6 +1020,7 @@ inventory-items: manufacturer.save() platform = Platform(name='Platform', slug='test-platform', manufacturer=manufacturer) platform.save() + ModuleBayType.objects.create(name='SFP28', slug='sfp28') # Add all required permissions to the test user self.add_permissions( @@ -1126,6 +1129,7 @@ inventory-items: self.assertEqual(device_type.modulebaytemplates.count(), 3) mb1 = ModuleBayTemplate.objects.first() self.assertEqual(mb1.name, 'Module Bay 1') + self.assertEqual(list(mb1.module_bay_types.values_list('name', flat=True)), ['SFP28']) self.assertEqual(device_type.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() @@ -1648,6 +1652,8 @@ port-mappings: module-bays: - name: Module Bay 1 position: 1 + module_bay_types: + - SFP28 - name: Module Bay 2 position: 2 - name: Module Bay 3 @@ -1657,6 +1663,7 @@ module-bays: # Create the manufacturer manufacturer = Manufacturer(name='Generic', slug='generic') manufacturer.save() + ModuleBayType.objects.create(name='SFP28', slug='sfp28') # Add all required permissions to the test user self.add_permissions( @@ -1752,6 +1759,7 @@ module-bays: mb1 = ModuleBayTemplate.objects.first() self.assertEqual(mb1.name, 'Module Bay 1') self.assertEqual(mb1.position, '1') + self.assertEqual(list(mb1.module_bay_types.values_list('name', flat=True)), ['SFP28']) @override_settings(STREAMING_EXPORTS=True) def test_export_objects(self):