diff --git a/docs/models/dcim/modulebay.md b/docs/models/dcim/modulebay.md index c7790951141..494012a7bc7 100644 --- a/docs/models/dcim/modulebay.md +++ b/docs/models/dcim/modulebay.md @@ -14,6 +14,10 @@ Module bays represent a space or slot within a device in which a field-replaceab The device to which this module bay belongs. +### Module + +The module to which this bay belongs (optional). + ### Name The module bay's name. Must be unique to the parent device. diff --git a/netbox/dcim/api/serializers_/device_components.py b/netbox/dcim/api/serializers_/device_components.py index ab3944723b7..06451043b53 100644 --- a/netbox/dcim/api/serializers_/device_components.py +++ b/netbox/dcim/api/serializers_/device_components.py @@ -297,6 +297,13 @@ class Meta: class ModuleBaySerializer(NetBoxModelSerializer): device = DeviceSerializer(nested=True) + module = ModuleSerializer( + nested=True, + fields=('id', 'url', 'display', 'module_bay'), + required=False, + allow_null=True, + default=None + ) installed_module = ModuleSerializer( nested=True, fields=('id', 'url', 'display', 'serial', 'description'), @@ -307,7 +314,7 @@ class ModuleBaySerializer(NetBoxModelSerializer): class Meta: model = ModuleBay fields = [ - 'id', 'url', 'display_url', 'display', 'device', 'name', 'installed_module', 'label', 'position', + 'id', 'url', 'display_url', 'display', 'device', 'module', 'name', 'installed_module', 'label', 'position', 'description', 'tags', 'custom_fields', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description') diff --git a/netbox/dcim/api/serializers_/devicetype_components.py b/netbox/dcim/api/serializers_/devicetype_components.py index b03fbc63452..f4d09e7fb1b 100644 --- a/netbox/dcim/api/serializers_/devicetype_components.py +++ b/netbox/dcim/api/serializers_/devicetype_components.py @@ -253,13 +253,22 @@ class Meta: class ModuleBayTemplateSerializer(ValidatedModelSerializer): device_type = DeviceTypeSerializer( - nested=True + nested=True, + required=False, + allow_null=True, + default=None + ) + module_type = ModuleTypeSerializer( + nested=True, + required=False, + allow_null=True, + default=None ) class Meta: model = ModuleBayTemplate fields = [ - 'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description', + 'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description', 'created', 'last_updated', ] brief_fields = ('id', 'url', 'display', 'name', 'description') diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 255b9bd50a4..3607eda2ca1 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -858,7 +858,7 @@ class Meta: fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description') -class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): +class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet): class Meta: model = ModuleBayTemplate @@ -1322,11 +1322,11 @@ class ModuleFilterSet(NetBoxModelFilterSet): to_field_name='model', label=_('Module type (model)'), ) - module_bay_id = django_filters.ModelMultipleChoiceFilter( - field_name='module_bay', + module_bay_id = TreeNodeMultipleChoiceFilter( queryset=ModuleBay.objects.all(), - to_field_name='id', - label=_('Module Bay (ID)') + field_name='module_bay', + lookup_expr='in', + label=_('Module bay (ID)'), ) device_id = django_filters.ModelMultipleChoiceFilter( queryset=Device.objects.all(), @@ -1793,7 +1793,11 @@ class Meta: ) -class ModuleBayFilterSet(DeviceComponentFilterSet, NetBoxModelFilterSet): +class ModuleBayFilterSet(ModularDeviceComponentFilterSet, NetBoxModelFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=ModuleBay.objects.all(), + label=_('Parent module bay (ID)'), + ) installed_module_id = django_filters.ModelMultipleChoiceFilter( field_name='installed_module', queryset=ModuleBay.objects.all(), diff --git a/netbox/dcim/forms/common.py b/netbox/dcim/forms/common.py index 3be4d08e8ec..4341ec04110 100644 --- a/netbox/dcim/forms/common.py +++ b/netbox/dcim/forms/common.py @@ -70,6 +70,18 @@ def clean(self): class ModuleCommonForm(forms.Form): + def _get_module_bay_tree(self, module_bay): + module_bays = [] + while module_bay: + module_bays.append(module_bay) + if module_bay.module: + module_bay = module_bay.module.module_bay + else: + module_bay = None + + module_bays.reverse() + return module_bays + def clean(self): super().clean() @@ -88,6 +100,8 @@ def clean(self): self.instance._disable_replication = True return + module_bays = self._get_module_bay_tree(module_bay) + for templates, component_attribute in [ ("consoleporttemplates", "consoleports"), ("consoleserverporttemplates", "consoleserverports"), @@ -104,13 +118,24 @@ def clean(self): # Get the templates for the module type. for template in getattr(module_type, templates).all(): + resolved_name = template.name # Installing modules with placeholders require that the bay has a position value - if MODULE_TOKEN in template.name and not module_bay.position: - raise forms.ValidationError( - _("Cannot install module with placeholder values in a module bay with no position defined.") - ) + if MODULE_TOKEN in template.name: + if not module_bay.position: + raise forms.ValidationError( + _("Cannot install module with placeholder values in a module bay with no position defined.") + ) + + if len(module_bays) != template.name.count(MODULE_TOKEN): + raise forms.ValidationError( + _("Cannot install module with placeholder values in a module bay tree {level} in tree but {tokens} placeholders given.").format( + level=len(module_bays), tokens=template.name.count(MODULE_TOKEN) + ) + ) + + for module_bay in module_bays: + resolved_name = resolved_name.replace(MODULE_TOKEN, module_bay.position, 1) - resolved_name = template.name.replace(MODULE_TOKEN, module_bay.position) existing_item = installed_components.get(resolved_name) # It is not possible to adopt components already belonging to a module diff --git a/netbox/dcim/forms/model_forms.py b/netbox/dcim/forms/model_forms.py index 4a9e2f25bb2..d1ea12d4b11 100644 --- a/netbox/dcim/forms/model_forms.py +++ b/netbox/dcim/forms/model_forms.py @@ -1033,15 +1033,15 @@ class Meta: ] -class ModuleBayTemplateForm(ComponentTemplateForm): +class ModuleBayTemplateForm(ModularComponentTemplateForm): fieldsets = ( - FieldSet('device_type', 'name', 'label', 'position', 'description'), + FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'), ) class Meta: model = ModuleBayTemplate fields = [ - 'device_type', 'name', 'label', 'position', 'description', + 'device_type', 'module_type', 'name', 'label', 'position', 'description', ] @@ -1453,15 +1453,15 @@ class Meta: ] -class ModuleBayForm(DeviceComponentForm): +class ModuleBayForm(ModularDeviceComponentForm): fieldsets = ( - FieldSet('device', 'name', 'label', 'position', 'description', 'tags',), + FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',), ) class Meta: model = ModuleBay fields = [ - 'device', 'name', 'label', 'position', 'description', 'tags', + 'device', 'module', 'name', 'label', 'position', 'description', 'tags', ] diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index ac254961665..24ba5cca4f5 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -496,12 +496,18 @@ class ModuleType(NetBoxObjectType): @strawberry_django.type( models.ModuleBay, - fields='__all__', + # fields='__all__', + exclude=('parent',), filters=ModuleBayFilter ) -class ModuleBayType(ComponentType): +class ModuleBayType(ModularComponentType): installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None + children: List[Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')]] + + @strawberry_django.field + def parent(self) -> Annotated["ModuleBayType", strawberry.lazy('dcim.graphql.types')] | None: + return self.parent @strawberry_django.type( @@ -509,7 +515,7 @@ class ModuleBayType(ComponentType): fields='__all__', filters=ModuleBayTemplateFilter ) -class ModuleBayTemplateType(ComponentTemplateType): +class ModuleBayTemplateType(ModularComponentTemplateType): _name: str diff --git a/netbox/dcim/migrations/0190_nested_modules.py b/netbox/dcim/migrations/0190_nested_modules.py new file mode 100644 index 00000000000..135baedd7e4 --- /dev/null +++ b/netbox/dcim/migrations/0190_nested_modules.py @@ -0,0 +1,74 @@ +import django.db.models.deletion +import mptt.fields +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0189_moduletype_airflow_rack_airflow_racktype_airflow'), + ('extras', '0120_customfield_related_object_filter'), + ] + + operations = [ + migrations.AlterModelOptions( + name='modulebaytemplate', + options={'ordering': ('device_type', 'module_type', '_name')}, + ), + migrations.RemoveConstraint( + model_name='modulebay', + name='dcim_modulebay_unique_device_name', + ), + migrations.AddField( + model_name='modulebay', + name='level', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='modulebay', + name='lft', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='modulebay', + name='module', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.module'), + ), + migrations.AddField( + model_name='modulebay', + name='parent', + field=mptt.fields.TreeForeignKey(blank=True, editable=False, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='children', to='dcim.modulebay'), + ), + migrations.AddField( + model_name='modulebay', + name='rght', + field=models.PositiveIntegerField(default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='modulebay', + name='tree_id', + field=models.PositiveIntegerField(db_index=True, default=0, editable=False), + preserve_default=False, + ), + migrations.AddField( + model_name='modulebaytemplate', + name='module_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.moduletype'), + ), + migrations.AlterField( + model_name='modulebaytemplate', + name='device_type', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='%(class)ss', to='dcim.devicetype'), + ), + migrations.AddConstraint( + model_name='modulebay', + constraint=models.UniqueConstraint(fields=('device', 'module', 'name'), name='dcim_modulebay_unique_device_module_name'), + ), + migrations.AddConstraint( + model_name='modulebaytemplate', + constraint=models.UniqueConstraint(fields=('module_type', 'name'), name='dcim_modulebaytemplate_unique_module_type_name'), + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index dacd7ec3ed9..28a403be03e 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -158,14 +158,41 @@ def clean(self): _("A component template must be associated with either a device type or a module type.") ) + def _get_module_tree(self, module): + modules = [] + all_module_bays = module.device.modulebays.all().select_related('module') + while module: + modules.append(module) + if module.module_bay: + module = module.module_bay.module + else: + module = None + + modules.reverse() + return modules + def resolve_name(self, module): + if MODULE_TOKEN not in self.name: + return self.name + if module: - return self.name.replace(MODULE_TOKEN, module.module_bay.position) + modules = self._get_module_tree(module) + name = self.name + for module in modules: + name = name.replace(MODULE_TOKEN, module.module_bay.position, 1) + return name return self.name def resolve_label(self, module): + if MODULE_TOKEN not in self.label: + return self.label + if module: - return self.label.replace(MODULE_TOKEN, module.module_bay.position) + modules = self._get_module_tree(module) + label = self.label + for module in modules: + label = label.replace(MODULE_TOKEN, module.module_bay.position, 1) + return label return self.label @@ -628,7 +655,7 @@ def to_yaml(self): } -class ModuleBayTemplate(ComponentTemplateModel): +class ModuleBayTemplate(ModularComponentTemplateModel): """ A template for a ModuleBay to be created for a new parent Device. """ @@ -641,16 +668,16 @@ class ModuleBayTemplate(ComponentTemplateModel): component_model = ModuleBay - class Meta(ComponentTemplateModel.Meta): + class Meta(ModularComponentTemplateModel.Meta): verbose_name = _('module bay template') verbose_name_plural = _('module bay templates') - def instantiate(self, device): + def instantiate(self, **kwargs): return self.component_model( - device=device, name=self.name, label=self.label, - position=self.position + position=self.position, + **kwargs ) instantiate.do_not_call_in_templates = True diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 9438b741f58..62312cbf434 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -4,7 +4,7 @@ from django.core.exceptions import ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models -from django.db.models import Sum +from django.db.models import F, Sum from django.urls import reverse from django.utils.translation import gettext_lazy as _ from mptt.models import MPTTModel, TreeForeignKey @@ -1087,10 +1087,19 @@ def clean(self): # Bays # -class ModuleBay(ComponentModel, TrackingModelMixin): +class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel): """ An empty space within a Device which can house a child device """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='children', + blank=True, + null=True, + editable=False, + db_index=True + ) position = models.CharField( verbose_name=_('position'), max_length=30, @@ -1098,15 +1107,45 @@ class ModuleBay(ComponentModel, TrackingModelMixin): help_text=_('Identifier to reference when renaming installed components') ) + objects = TreeManager() + clone_fields = ('device',) - class Meta(ComponentModel.Meta): + class Meta(ModularComponentModel.Meta): + constraints = ( + models.UniqueConstraint( + fields=('device', 'module', 'name'), + name='%(app_label)s_%(class)s_unique_device_module_name' + ), + ) verbose_name = _('module bay') verbose_name_plural = _('module bays') + class MPTTMeta: + order_insertion_by = ('module',) + def get_absolute_url(self): return reverse('dcim:modulebay', kwargs={'pk': self.pk}) + def clean(self): + super().clean() + + # Check for recursion + if module := self.module: + module_bays = [self.pk] + modules = [] + while module: + if module.pk in modules or module.module_bay.pk in module_bays: + raise ValidationError(_("A module bay cannot belong to a module installed within it.")) + modules.append(module.pk) + module_bays.append(module.module_bay.pk) + module = module.module_bay.module if module.module_bay else None + + def save(self, *args, **kwargs): + if self.module: + self.parent = self.module.module_bay + super().save(*args, **kwargs) + class DeviceBay(ComponentModel, TrackingModelMixin): """ diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 14c6ac8abd6..c281e5de231 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -1046,7 +1046,8 @@ def save(self, *args, **kwargs): self._instantiate_components(self.device_type.interfacetemplates.all()) self._instantiate_components(self.device_type.rearporttemplates.all()) self._instantiate_components(self.device_type.frontporttemplates.all()) - self._instantiate_components(self.device_type.modulebaytemplates.all()) + # Disable bulk_create to accommodate MPTT + self._instantiate_components(self.device_type.modulebaytemplates.all(), bulk_create=False) self._instantiate_components(self.device_type.devicebaytemplates.all()) # Disable bulk_create to accommodate MPTT self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False) @@ -1207,6 +1208,17 @@ def clean(self): ) ) + # Check for recursion + module = self + module_bays = [] + modules = [] + while module: + if module.pk in modules or module.module_bay.pk in module_bays: + raise ValidationError(_("A module bay cannot belong to a module installed within it.")) + modules.append(module.pk) + module_bays.append(module.module_bay.pk) + module = module.module_bay.module if module.module_bay else None + def save(self, *args, **kwargs): is_new = self.pk is None @@ -1228,7 +1240,8 @@ def save(self, *args, **kwargs): ("powerporttemplates", "powerports", PowerPort), ("poweroutlettemplates", "poweroutlets", PowerOutlet), ("rearporttemplates", "rearports", RearPort), - ("frontporttemplates", "frontports", FrontPort) + ("frontporttemplates", "frontports", FrontPort), + ("modulebaytemplates", "modulebays", ModuleBay), ]: create_instances = [] update_instances = [] @@ -1257,17 +1270,22 @@ def save(self, *args, **kwargs): if not disable_replication: create_instances.append(template_instance) - component_model.objects.bulk_create(create_instances) - # Emit the post_save signal for each newly created object - for component in create_instances: - post_save.send( - sender=component_model, - instance=component, - created=True, - raw=False, - using='default', - update_fields=None - ) + if component_model is not ModuleBay: + component_model.objects.bulk_create(create_instances) + # Emit the post_save signal for each newly created object + for component in create_instances: + post_save.send( + sender=component_model, + instance=component, + created=True, + raw=False, + using='default', + update_fields=None + ) + else: + # ModuleBays must be saved individually for MPTT + for instance in create_instances: + instance.save() update_fields = ['module'] component_model.objects.bulk_update(update_instances, update_fields) diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index d6435ed4bf6..6c0aac76c50 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -313,6 +313,9 @@ class ModularDeviceComponentTable(DeviceComponentTable): verbose_name=_('Inventory Items'), ) + class Meta(NetBoxTable.Meta): + pass + class CableTerminationTable(NetBoxTable): cable = tables.Column( @@ -844,7 +847,7 @@ class Meta(DeviceComponentTable.Meta): default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description') -class ModuleBayTable(DeviceComponentTable): +class ModuleBayTable(ModularDeviceComponentTable): device = tables.Column( verbose_name=_('Device'), linkify={ @@ -852,6 +855,10 @@ class ModuleBayTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + parent = tables.Column( + linkify=True, + verbose_name=_('Parent'), + ) installed_module = tables.Column( linkify=True, verbose_name=_('Installed Module') @@ -873,25 +880,40 @@ class ModuleBayTable(DeviceComponentTable): verbose_name=_('Module Status') ) - class Meta(DeviceComponentTable.Meta): + class Meta(ModularDeviceComponentTable.Meta): model = models.ModuleBay fields = ( - 'pk', 'id', 'name', 'device', 'label', 'position', 'installed_module', 'module_status', 'module_serial', - 'module_asset_tag', 'description', 'tags', + 'pk', 'id', 'name', 'device', 'parent', 'label', 'position', 'installed_module', 'module_status', + 'module_serial', 'module_asset_tag', 'description', 'tags', ) - default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'module_status', 'description') + default_columns = ( + 'pk', 'name', 'device', 'parent', 'label', 'installed_module', 'module_status', 'description', + ) + + def render_parent_bay(self, value): + return value.name if value else '' + + def render_installed_module(self, value): + return value.module_type if value else '' class DeviceModuleBayTable(ModuleBayTable): + name = tables.TemplateColumn( + verbose_name=_('Name'), + template_code='' + '{{ value }}', + order_by=Accessor('_name'), + attrs={'td': {'class': 'text-nowrap'}} + ) actions = columns.ActionsColumn( extra_buttons=MODULEBAY_BUTTONS ) - class Meta(DeviceComponentTable.Meta): + class Meta(ModuleBayTable.Meta): model = models.ModuleBay fields = ( - 'pk', 'id', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', 'module_asset_tag', - 'description', 'tags', 'actions', + 'pk', 'id', 'parent', 'name', 'label', 'position', 'installed_module', 'module_status', 'module_serial', + 'module_asset_tag', 'description', 'tags', 'actions', ) default_columns = ('pk', 'name', 'label', 'installed_module', 'module_status', 'description') diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b690007be71..7a9d124bbe5 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1352,7 +1352,8 @@ def setUpTestData(cls): ModuleBay(device=device, name='Module Bay 5'), ModuleBay(device=device, name='Module Bay 6'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=device, module_bay=module_bays[0], module_type=module_types[0]), @@ -1810,12 +1811,13 @@ def setUpTestData(cls): device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1') device = Device.objects.create(device_type=device_type, role=role, name='Device 1', site=site) - device_bays = ( + module_bays = ( ModuleBay(device=device, name='Device Bay 1'), ModuleBay(device=device, name='Device Bay 2'), ModuleBay(device=device, name='Device Bay 3'), ) - ModuleBay.objects.bulk_create(device_bays) + for module_bay in module_bays: + module_bay.save() cls.create_data = [ { diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 15681547443..942523a0fa2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1871,16 +1871,27 @@ def setUpTestData(cls): ) DeviceType.objects.bulk_create(device_types) + module_types = ( + ModuleType(manufacturer=manufacturer, model='Module Type 1'), + ModuleType(manufacturer=manufacturer, model='Module Type 2'), + ) + ModuleType.objects.bulk_create(module_types) + ModuleBayTemplate.objects.bulk_create(( ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1', description='foobar1'), - ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2', description='foobar2'), - ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3', description='foobar3'), + ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2', description='foobar2', module_type=module_types[0]), + ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3', description='foobar3', module_type=module_types[1]), )) def test_name(self): params = {'name': ['Module Bay 1', 'Module Bay 2']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_module_type(self): + module_types = ModuleType.objects.all()[:2] + params = {'module_type_id': [module_types[0].pk, module_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests): queryset = DeviceBayTemplate.objects.all() @@ -2309,10 +2320,8 @@ def setUpTestData(cls): FrontPort(device=devices[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]), FrontPort(device=devices[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]), )) - ModuleBay.objects.bulk_create(( - ModuleBay(device=devices[0], name='Module Bay 1'), - ModuleBay(device=devices[1], name='Module Bay 2'), - )) + ModuleBay.objects.create(device=devices[0], name='Module Bay 1') + ModuleBay.objects.create(device=devices[1], name='Module Bay 2') DeviceBay.objects.bulk_create(( DeviceBay(device=devices[0], name='Device Bay 1'), DeviceBay(device=devices[1], name='Device Bay 2'), @@ -2624,7 +2633,8 @@ def setUpTestData(cls): ModuleBay(device=devices[2], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module( @@ -2827,7 +2837,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -3007,7 +3018,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -3187,7 +3199,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -3375,7 +3388,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -3606,7 +3620,8 @@ def setUpTestData(cls): ModuleBay(device=devices[2], name='Module Bay 3'), ModuleBay(device=devices[3], name='Module Bay 4'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -4053,7 +4068,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -4242,7 +4258,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 2'), ModuleBay(device=devices[2], name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), @@ -4421,8 +4438,22 @@ def setUpTestData(cls): ModuleBay(device=devices[0], name='Module Bay 1', label='A', description='First'), ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'), ModuleBay(device=devices[2], name='Module Bay 3', label='C', description='Third'), + ModuleBay(device=devices[2], name='Module Bay 4', label='D', description='Fourth'), + ModuleBay(device=devices[2], name='Module Bay 5', label='E', description='Fifth'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() + + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + modules = ( + Module(device=devices[0], module_bay=module_bays[0], module_type=module_type), + Module(device=devices[1], module_bay=module_bays[1], module_type=module_type), + ) + Module.objects.bulk_create(modules) + module_bays[3].module = modules[0] + module_bays[3].save() + module_bays[4].module = modules[1] + module_bays[4].save() def test_name(self): params = {'name': ['Module Bay 1', 'Module Bay 2']} @@ -4478,6 +4509,11 @@ def test_device(self): params = {'device': [devices[0].name, devices[1].name]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_module(self): + modules = Module.objects.all()[:2] + params = {'module_id': [modules[0].pk, modules[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests): queryset = DeviceBay.objects.all() diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 148b9e35ffe..1c3dbb90b93 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -620,6 +620,100 @@ def test_device_mismatched_site_cluster(self): Device(name='device1', site=sites[0], device_type=device_type, role=device_role, cluster=clusters[1]).full_clean() +class ModuleBayTestCase(TestCase): + + @classmethod + def setUpTestData(cls): + site = Site.objects.create(name='Test Site 1', slug='test-site-1') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + device_type = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' + ) + device_role = DeviceRole.objects.create(name='Test Role 1', slug='test-role-1') + + # Create a CustomField with a default value & assign it to all component models + location = Location.objects.create(name='Location 1', slug='location-1', site=site) + rack = Rack.objects.create(name='Rack 1', site=site) + device = Device.objects.create(name='Device 1', device_type=device_type, role=device_role, site=site, location=location, rack=rack) + + module_bays = ( + ModuleBay(device=device, name='Module Bay 1', label='A', description='First'), + ModuleBay(device=device, name='Module Bay 2', label='B', description='Second'), + ModuleBay(device=device, name='Module Bay 3', label='C', description='Third'), + ) + for module_bay in module_bays: + module_bay.save() + + manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1') + module_type = ModuleType.objects.create(manufacturer=manufacturer, model='Module Type 1') + modules = ( + Module(device=device, module_bay=module_bays[0], module_type=module_type), + Module(device=device, module_bay=module_bays[1], module_type=module_type), + Module(device=device, module_bay=module_bays[2], module_type=module_type), + ) + # M3 -> MB3 -> M2 -> MB2 -> M1 -> MB1 + Module.objects.bulk_create(modules) + module_bays[1].module = modules[0] + module_bays[1].clean() + module_bays[1].save() + module_bays[2].module = modules[1] + module_bays[2].clean() + module_bays[2].save() + + def test_module_bay_recursion(self): + module_bay_1 = ModuleBay.objects.get(name='Module Bay 1') + module_bay_2 = ModuleBay.objects.get(name='Module Bay 2') + module_bay_3 = ModuleBay.objects.get(name='Module Bay 3') + module_1 = Module.objects.get(module_bay=module_bay_1) + module_2 = Module.objects.get(module_bay=module_bay_2) + module_3 = Module.objects.get(module_bay=module_bay_3) + + # Confirm error if ModuleBay recurses + with self.assertRaises(ValidationError): + module_bay_1.module = module_3 + module_bay_1.clean() + module_bay_1.save() + + # Confirm error if Module recurses + with self.assertRaises(ValidationError): + module_1.module_bay = module_bay_3 + module_1.clean() + module_1.save() + + def test_single_module_token(self): + module_bays = ModuleBay.objects.all() + modules = Module.objects.all() + device_type = DeviceType.objects.first() + device_role = DeviceRole.objects.first() + site = Site.objects.first() + location = Location.objects.first() + rack = Rack.objects.first() + + # Create DeviceType components + ConsolePortTemplate.objects.create( + device_type=device_type, + name='{module}', + label='{module}', + ) + ModuleBayTemplate.objects.create( + device_type=device_type, + name='Module Bay 1' + ) + + device = Device.objects.create( + name='Device 2', + device_type=device_type, + role=device_role, + site=site, + location=location, + rack=rack + ) + cp = device.consoleports.first() + + def test_nested_module_token(self): + pass + + class CableTestCase(TestCase): @classmethod diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index c8763e5b9b8..bb59a5752d6 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -1899,12 +1899,9 @@ def test_device_frontports(self): @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_device_modulebays(self): device = Device.objects.first() - device_bays = ( - ModuleBay(device=device, name='Module Bay 1'), - ModuleBay(device=device, name='Module Bay 2'), - ModuleBay(device=device, name='Module Bay 3'), - ) - ModuleBay.objects.bulk_create(device_bays) + ModuleBay.objects.create(device=device, name='Module Bay 1') + ModuleBay.objects.create(device=device, name='Module Bay 2') + ModuleBay.objects.create(device=device, name='Module Bay 3') url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk}) self.assertHttpStatus(self.client.get(url), 200) @@ -1980,7 +1977,8 @@ def setUpTestData(cls): ModuleBay(device=devices[1], name='Module Bay 4'), ModuleBay(device=devices[1], name='Module Bay 5'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() modules = ( Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]), @@ -2782,7 +2780,8 @@ def setUpTestData(cls): ModuleBay(device=device, name='Module Bay 2'), ModuleBay(device=device, name='Module Bay 3'), ) - ModuleBay.objects.bulk_create(module_bays) + for module_bay in module_bays: + module_bay.save() tags = create_tags('Alpha', 'Bravo', 'Charlie') diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5ad619452d3..ac6f51e8cea 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1314,6 +1314,21 @@ class ModuleTypeRearPortsView(ModuleTypeComponentsView): ) +@register_model_view(ModuleType, 'modulebays', path='module-bays') +class ModuleTypeModuleBaysView(ModuleTypeComponentsView): + child_model = ModuleBayTemplate + table = tables.ModuleBayTemplateTable + filterset = filtersets.ModuleBayTemplateFilterSet + viewname = 'dcim:moduletype_modulebays' + tab = ViewTab( + label=_('Module Bays'), + badge=lambda obj: obj.modulebaytemplates.count(), + permission='dcim.view_modulebaytemplate', + weight=570, + hide_if_empty=True + ) + + class ModuleTypeImportView(generic.BulkImportView): additional_permissions = [ 'dcim.add_moduletype', diff --git a/netbox/templates/dcim/module.html b/netbox/templates/dcim/module.html index 0bfc74983c7..7019d20a348 100644 --- a/netbox/templates/dcim/module.html +++ b/netbox/templates/dcim/module.html @@ -39,6 +39,9 @@ {% if perms.dcim.add_rearport %}