Ver código fonte

Closes #10500: Introduce support for nested modules (#16983)

* 10500 add ModularComponentModel

* 10500 add ModularComponentModel

* 10500 add to forms

* 10500 add to serializer, tables

* 10500 template

* 10500 add docs

* 10500 check recursion

* 10500 fix graphql

* 10500 fix conflicting migration from merge

* 10500 token resolution

* 10500 don't return reverse

* 10500 don't return reverse / optimize

* Add ModuleTypeModuleBaysView

* Fix replication of module bays on new modules

* Clean up tables & templates

* Adjust uniqueness constraints

* Correct URL

* Clean up docs

* Fix up serializers

* 10500 add filterset tests

* 10500 add nested validation to Module

* Misc cleanup

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* 10500 ModuleBay recursion Test

* Enable MPTT for module bays

* Fix tests

* Fix validation of module token in component names

* Misc cleanup

* Merge migrations

* Fix table ordering

---------

Co-authored-by: Jeremy Stretch <jstretch@netboxlabs.com>
Arthur Hanson 1 ano atrás
pai
commit
796b9e84af

+ 4 - 0
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.

+ 8 - 1
netbox/dcim/api/serializers_/device_components.py

@@ -297,6 +297,13 @@ class FrontPortSerializer(NetBoxModelSerializer, CabledObjectSerializer):
 
 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')

+ 11 - 2
netbox/dcim/api/serializers_/devicetype_components.py

@@ -253,13 +253,22 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
 
 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')

+ 10 - 6
netbox/dcim/filtersets.py

@@ -858,7 +858,7 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeCom
         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 RearPortFilterSet(
         )
 
 
-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(),

+ 30 - 5
netbox/dcim/forms/common.py

@@ -70,6 +70,18 @@ class InterfaceCommonForm(forms.Form):
 
 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 @@ class ModuleCommonForm(forms.Form):
             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 @@ class ModuleCommonForm(forms.Form):
 
             # 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

+ 6 - 6
netbox/dcim/forms/model_forms.py

@@ -1033,15 +1033,15 @@ class RearPortTemplateForm(ModularComponentTemplateForm):
         ]
 
 
-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 RearPortForm(ModularDeviceComponentForm):
         ]
 
 
-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',
         ]
 
 

+ 9 - 3
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
 
 

+ 74 - 0
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'),
+        ),
+    ]

+ 34 - 7
netbox/dcim/models/device_component_templates.py

@@ -158,14 +158,41 @@ class ModularComponentTemplateModel(ComponentTemplateModel):
                 _("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 @@ class RearPortTemplate(ModularComponentTemplateModel):
         }
 
 
-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
 

+ 42 - 3
netbox/dcim/models/device_components.py

@@ -4,7 +4,7 @@ from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelatio
 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 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 # 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):
     """

+ 31 - 13
netbox/dcim/models/devices.py

@@ -1046,7 +1046,8 @@ class Device(
             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 @@ class Module(PrimaryModel, ConfigContextModel):
                 )
             )
 
+        # 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 @@ class Module(PrimaryModel, ConfigContextModel):
             ("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 @@ class Module(PrimaryModel, ConfigContextModel):
                 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)

+ 30 - 8
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 DeviceDeviceBayTable(DeviceBayTable):
         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='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'
+                      '{{ value }}</a>',
+        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')
 

+ 5 - 3
netbox/dcim/tests/test_api.py

@@ -1352,7 +1352,8 @@ class ModuleTest(APIViewTestCases.APIViewTestCase):
             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 @@ class ModuleBayTest(APIViewTestCases.APIViewTestCase):
         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 = [
             {

+ 51 - 15
netbox/dcim/tests/test_filtersets.py

@@ -1871,16 +1871,27 @@ class ModuleBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests,
         )
         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 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
             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 @@ class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
             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 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             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 @@ class ConsoleServerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeL
             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 @@ class PowerPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             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 @@ class PowerOutletTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             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 @@ class InterfaceTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             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 @@ class FrontPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             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 @@ class RearPortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilt
             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 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
             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 @@ class ModuleBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFil
         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()

+ 94 - 0
netbox/dcim/tests/test_models.py

@@ -620,6 +620,100 @@ class DeviceTestCase(TestCase):
             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

+ 7 - 8
netbox/dcim/tests/test_views.py

@@ -1899,12 +1899,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     @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 @@ class ModuleTestCase(
             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 @@ class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
             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')
 

+ 15 - 0
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',

+ 3 - 0
netbox/templates/dcim/module.html

@@ -39,6 +39,9 @@
         {% if perms.dcim.add_rearport %}
           <li><a class="dropdown-item" href="{% url 'dcim:rearport_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_rearports' pk=object.device.pk %}">{% trans "Rear Ports" %}</a></li>
         {% endif %}
+        {% if perms.dcim.add_modulebay %}
+          <li><a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.device.pk }}&module={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
+        {% endif %}
       </ul>
     </div>
   {% endif %}

+ 6 - 2
netbox/templates/dcim/modulebay.html

@@ -22,6 +22,10 @@
               <a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a>
             </td>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Module" %}</th>
+            <td>{{ object.module|linkify|placeholder }}</td>
+          </tr>
           <tr>
             <th scope="row">{% trans "Name" %}</th>
             <td>{{ object.name }}</td>
@@ -31,8 +35,8 @@
             <td>{{ object.label|placeholder }}</td>
           </tr>
           <tr>
-              <th scope="row">{% trans "Position" %}</th>
-              <td>{{ object.position|placeholder }}</td>
+            <th scope="row">{% trans "Position" %}</th>
+            <td>{{ object.position|placeholder }}</td>
           </tr>
           <tr>
             <th scope="row">{% trans "Description" %}</th>

+ 2 - 4
netbox/templates/dcim/moduletype.html

@@ -27,10 +27,8 @@
             <td>{{ object.description|placeholder }}</td>
           </tr>
           <tr>
-              <th scope="row">{% trans "Airflow" %}</th>
-              <td>
-                  {{ object.get_airflow_display|placeholder }}
-              </td>
+            <th scope="row">{% trans "Airflow" %}</th>
+            <td>{{ object.get_airflow_display|placeholder }}</td>
           </tr>
           <tr>
             <th scope="row">{% trans "Weight" %}</th>

+ 3 - 0
netbox/templates/dcim/moduletype/base.html

@@ -39,6 +39,9 @@
         {% if perms.dcim.add_rearporttemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_rearports' pk=object.pk %}">{% trans "Rear Ports" %}</a></li>
         {% endif %}
+        {% if perms.dcim.add_modulebaytemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:modulebaytemplate_add' %}?module_type={{ object.pk }}&return_url={% url 'dcim:moduletype_modulebays' pk=object.pk %}">{% trans "Module Bays" %}</a></li>
+        {% endif %}
       </ul>
     </div>
   {% endif %}