Просмотр исходного кода

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 год назад
Родитель
Сommit
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.
 The device to which this module bay belongs.
 
 
+### Module
+
+The module to which this bay belongs (optional).
+
 ### Name
 ### Name
 
 
 The module bay's name. Must be unique to the parent device.
 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):
 class ModuleBaySerializer(NetBoxModelSerializer):
     device = DeviceSerializer(nested=True)
     device = DeviceSerializer(nested=True)
+    module = ModuleSerializer(
+        nested=True,
+        fields=('id', 'url', 'display', 'module_bay'),
+        required=False,
+        allow_null=True,
+        default=None
+    )
     installed_module = ModuleSerializer(
     installed_module = ModuleSerializer(
         nested=True,
         nested=True,
         fields=('id', 'url', 'display', 'serial', 'description'),
         fields=('id', 'url', 'display', 'serial', 'description'),
@@ -307,7 +314,7 @@ class ModuleBaySerializer(NetBoxModelSerializer):
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
         fields = [
         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',
             'description', 'tags', 'custom_fields', 'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'installed_module', 'name', 'description')
         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):
 class ModuleBayTemplateSerializer(ValidatedModelSerializer):
     device_type = DeviceTypeSerializer(
     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:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
         fields = [
         fields = [
-            'id', 'url', 'display', 'device_type', 'name', 'label', 'position', 'description',
+            'id', 'url', 'display', 'device_type', 'module_type', 'name', 'label', 'position', 'description',
             'created', 'last_updated',
             'created', 'last_updated',
         ]
         ]
         brief_fields = ('id', 'url', 'display', 'name', 'description')
         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')
         fields = ('id', 'name', 'label', 'type', 'color', 'positions', 'description')
 
 
 
 
-class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
@@ -1322,11 +1322,11 @@ class ModuleFilterSet(NetBoxModelFilterSet):
         to_field_name='model',
         to_field_name='model',
         label=_('Module type (model)'),
         label=_('Module type (model)'),
     )
     )
-    module_bay_id = django_filters.ModelMultipleChoiceFilter(
-        field_name='module_bay',
+    module_bay_id = TreeNodeMultipleChoiceFilter(
         queryset=ModuleBay.objects.all(),
         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(
     device_id = django_filters.ModelMultipleChoiceFilter(
         queryset=Device.objects.all(),
         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(
     installed_module_id = django_filters.ModelMultipleChoiceFilter(
         field_name='installed_module',
         field_name='installed_module',
         queryset=ModuleBay.objects.all(),
         queryset=ModuleBay.objects.all(),

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

@@ -70,6 +70,18 @@ class InterfaceCommonForm(forms.Form):
 
 
 class ModuleCommonForm(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):
     def clean(self):
         super().clean()
         super().clean()
 
 
@@ -88,6 +100,8 @@ class ModuleCommonForm(forms.Form):
             self.instance._disable_replication = True
             self.instance._disable_replication = True
             return
             return
 
 
+        module_bays = self._get_module_bay_tree(module_bay)
+
         for templates, component_attribute in [
         for templates, component_attribute in [
                 ("consoleporttemplates", "consoleports"),
                 ("consoleporttemplates", "consoleports"),
                 ("consoleserverporttemplates", "consoleserverports"),
                 ("consoleserverporttemplates", "consoleserverports"),
@@ -104,13 +118,24 @@ class ModuleCommonForm(forms.Form):
 
 
             # Get the templates for the module type.
             # Get the templates for the module type.
             for template in getattr(module_type, templates).all():
             for template in getattr(module_type, templates).all():
+                resolved_name = template.name
                 # Installing modules with placeholders require that the bay has a position value
                 # 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)
                 existing_item = installed_components.get(resolved_name)
 
 
                 # It is not possible to adopt components already belonging to a module
                 # 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 = (
     fieldsets = (
-        FieldSet('device_type', 'name', 'label', 'position', 'description'),
+        FieldSet('device_type', 'module_type', 'name', 'label', 'position', 'description'),
     )
     )
 
 
     class Meta:
     class Meta:
         model = ModuleBayTemplate
         model = ModuleBayTemplate
         fields = [
         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 = (
     fieldsets = (
-        FieldSet('device', 'name', 'label', 'position', 'description', 'tags',),
+        FieldSet('device', 'module', 'name', 'label', 'position', 'description', 'tags',),
     )
     )
 
 
     class Meta:
     class Meta:
         model = ModuleBay
         model = ModuleBay
         fields = [
         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(
 @strawberry_django.type(
     models.ModuleBay,
     models.ModuleBay,
-    fields='__all__',
+    # fields='__all__',
+    exclude=('parent',),
     filters=ModuleBayFilter
     filters=ModuleBayFilter
 )
 )
-class ModuleBayType(ComponentType):
+class ModuleBayType(ModularComponentType):
 
 
     installed_module: Annotated["ModuleType", strawberry.lazy('dcim.graphql.types')] | None
     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(
 @strawberry_django.type(
@@ -509,7 +515,7 @@ class ModuleBayType(ComponentType):
     fields='__all__',
     fields='__all__',
     filters=ModuleBayTemplateFilter
     filters=ModuleBayTemplateFilter
 )
 )
-class ModuleBayTemplateType(ComponentTemplateType):
+class ModuleBayTemplateType(ModularComponentTemplateType):
     _name: str
     _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.")
                 _("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):
     def resolve_name(self, module):
+        if MODULE_TOKEN not in self.name:
+            return self.name
+
         if module:
         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
         return self.name
 
 
     def resolve_label(self, module):
     def resolve_label(self, module):
+        if MODULE_TOKEN not in self.label:
+            return self.label
+
         if module:
         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
         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.
     A template for a ModuleBay to be created for a new parent Device.
     """
     """
@@ -641,16 +668,16 @@ class ModuleBayTemplate(ComponentTemplateModel):
 
 
     component_model = ModuleBay
     component_model = ModuleBay
 
 
-    class Meta(ComponentTemplateModel.Meta):
+    class Meta(ModularComponentTemplateModel.Meta):
         verbose_name = _('module bay template')
         verbose_name = _('module bay template')
         verbose_name_plural = _('module bay templates')
         verbose_name_plural = _('module bay templates')
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return self.component_model(
         return self.component_model(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
-            position=self.position
+            position=self.position,
+            **kwargs
         )
         )
     instantiate.do_not_call_in_templates = True
     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.exceptions import ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 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.urls import reverse
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 from mptt.models import MPTTModel, TreeForeignKey
 from mptt.models import MPTTModel, TreeForeignKey
@@ -1087,10 +1087,19 @@ class RearPort(ModularComponentModel, CabledObjectModel, TrackingModelMixin):
 # Bays
 # Bays
 #
 #
 
 
-class ModuleBay(ComponentModel, TrackingModelMixin):
+class ModuleBay(ModularComponentModel, TrackingModelMixin, MPTTModel):
     """
     """
     An empty space within a Device which can house a child device
     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(
     position = models.CharField(
         verbose_name=_('position'),
         verbose_name=_('position'),
         max_length=30,
         max_length=30,
@@ -1098,15 +1107,45 @@ class ModuleBay(ComponentModel, TrackingModelMixin):
         help_text=_('Identifier to reference when renaming installed components')
         help_text=_('Identifier to reference when renaming installed components')
     )
     )
 
 
+    objects = TreeManager()
+
     clone_fields = ('device',)
     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 = _('module bay')
         verbose_name_plural = _('module bays')
         verbose_name_plural = _('module bays')
 
 
+    class MPTTMeta:
+        order_insertion_by = ('module',)
+
     def get_absolute_url(self):
     def get_absolute_url(self):
         return reverse('dcim:modulebay', kwargs={'pk': self.pk})
         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):
 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.interfacetemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.rearporttemplates.all())
             self._instantiate_components(self.device_type.frontporttemplates.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())
             self._instantiate_components(self.device_type.devicebaytemplates.all())
             # Disable bulk_create to accommodate MPTT
             # Disable bulk_create to accommodate MPTT
             self._instantiate_components(self.device_type.inventoryitemtemplates.all(), bulk_create=False)
             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):
     def save(self, *args, **kwargs):
         is_new = self.pk is None
         is_new = self.pk is None
 
 
@@ -1228,7 +1240,8 @@ class Module(PrimaryModel, ConfigContextModel):
             ("powerporttemplates", "powerports", PowerPort),
             ("powerporttemplates", "powerports", PowerPort),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
             ("poweroutlettemplates", "poweroutlets", PowerOutlet),
             ("rearporttemplates", "rearports", RearPort),
             ("rearporttemplates", "rearports", RearPort),
-            ("frontporttemplates", "frontports", FrontPort)
+            ("frontporttemplates", "frontports", FrontPort),
+            ("modulebaytemplates", "modulebays", ModuleBay),
         ]:
         ]:
             create_instances = []
             create_instances = []
             update_instances = []
             update_instances = []
@@ -1257,17 +1270,22 @@ class Module(PrimaryModel, ConfigContextModel):
                 if not disable_replication:
                 if not disable_replication:
                     create_instances.append(template_instance)
                     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']
             update_fields = ['module']
             component_model.objects.bulk_update(update_instances, update_fields)
             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'),
         verbose_name=_('Inventory Items'),
     )
     )
 
 
+    class Meta(NetBoxTable.Meta):
+        pass
+
 
 
 class CableTerminationTable(NetBoxTable):
 class CableTerminationTable(NetBoxTable):
     cable = tables.Column(
     cable = tables.Column(
@@ -844,7 +847,7 @@ class DeviceDeviceBayTable(DeviceBayTable):
         default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
         default_columns = ('pk', 'name', 'label', 'status', 'installed_device', 'description')
 
 
 
 
-class ModuleBayTable(DeviceComponentTable):
+class ModuleBayTable(ModularDeviceComponentTable):
     device = tables.Column(
     device = tables.Column(
         verbose_name=_('Device'),
         verbose_name=_('Device'),
         linkify={
         linkify={
@@ -852,6 +855,10 @@ class ModuleBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
             'args': [Accessor('device_id')],
         }
         }
     )
     )
+    parent = tables.Column(
+        linkify=True,
+        verbose_name=_('Parent'),
+    )
     installed_module = tables.Column(
     installed_module = tables.Column(
         linkify=True,
         linkify=True,
         verbose_name=_('Installed Module')
         verbose_name=_('Installed Module')
@@ -873,25 +880,40 @@ class ModuleBayTable(DeviceComponentTable):
         verbose_name=_('Module Status')
         verbose_name=_('Module Status')
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(ModularDeviceComponentTable.Meta):
         model = models.ModuleBay
         model = models.ModuleBay
         fields = (
         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):
 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(
     actions = columns.ActionsColumn(
         extra_buttons=MODULEBAY_BUTTONS
         extra_buttons=MODULEBAY_BUTTONS
     )
     )
 
 
-    class Meta(DeviceComponentTable.Meta):
+    class Meta(ModuleBayTable.Meta):
         model = models.ModuleBay
         model = models.ModuleBay
         fields = (
         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')
         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 5'),
             ModuleBay(device=device, name='Module Bay 6'),
             ModuleBay(device=device, name='Module Bay 6'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=device, module_bay=module_bays[0], module_type=module_types[0]),
             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_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 = 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 1'),
             ModuleBay(device=device, name='Device Bay 2'),
             ModuleBay(device=device, name='Device Bay 2'),
             ModuleBay(device=device, name='Device Bay 3'),
             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 = [
         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)
         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.objects.bulk_create((
             ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1', description='foobar1'),
             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):
     def test_name(self):
         params = {'name': ['Module Bay 1', 'Module Bay 2']}
         params = {'name': ['Module Bay 1', 'Module Bay 2']}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 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):
 class DeviceBayTemplateTestCase(TestCase, DeviceComponentTemplateFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = DeviceBayTemplate.objects.all()
     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[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]),
             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.objects.bulk_create((
             DeviceBay(device=devices[0], name='Device Bay 1'),
             DeviceBay(device=devices[0], name='Device Bay 1'),
             DeviceBay(device=devices[1], name='Device Bay 2'),
             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 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(
             Module(
@@ -2827,7 +2837,8 @@ class ConsolePortTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedF
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[2], name='Module Bay 3'),
             ModuleBay(device=devices[3], name='Module Bay 4'),
             ModuleBay(device=devices[3], name='Module Bay 4'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[1], name='Module Bay 2'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
             ModuleBay(device=devices[2], name='Module Bay 3'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_type),
             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[0], name='Module Bay 1', label='A', description='First'),
             ModuleBay(device=devices[1], name='Module Bay 2', label='B', description='Second'),
             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 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):
     def test_name(self):
         params = {'name': ['Module Bay 1', 'Module Bay 2']}
         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]}
         params = {'device': [devices[0].name, devices[1].name]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class DeviceBayTestCase(TestCase, DeviceComponentFilterSetTests, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     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()
             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):
 class CableTestCase(TestCase):
 
 
     @classmethod
     @classmethod

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

@@ -1899,12 +1899,9 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_modulebays(self):
     def test_device_modulebays(self):
         device = Device.objects.first()
         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})
         url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         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 4'),
             ModuleBay(device=devices[1], name='Module Bay 5'),
             ModuleBay(device=devices[1], name='Module Bay 5'),
         )
         )
-        ModuleBay.objects.bulk_create(module_bays)
+        for module_bay in module_bays:
+            module_bay.save()
 
 
         modules = (
         modules = (
             Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]),
             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 2'),
             ModuleBay(device=device, name='Module Bay 3'),
             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')
         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):
 class ModuleTypeImportView(generic.BulkImportView):
     additional_permissions = [
     additional_permissions = [
         'dcim.add_moduletype',
         'dcim.add_moduletype',

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

@@ -39,6 +39,9 @@
         {% if perms.dcim.add_rearport %}
         {% 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>
           <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 %}
         {% 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>
       </ul>
     </div>
     </div>
   {% endif %}
   {% 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>
               <a href="{% url 'dcim:device_modulebays' pk=object.device.pk %}">{{ object.device }}</a>
             </td>
             </td>
           </tr>
           </tr>
+          <tr>
+            <th scope="row">{% trans "Module" %}</th>
+            <td>{{ object.module|linkify|placeholder }}</td>
+          </tr>
           <tr>
           <tr>
             <th scope="row">{% trans "Name" %}</th>
             <th scope="row">{% trans "Name" %}</th>
             <td>{{ object.name }}</td>
             <td>{{ object.name }}</td>
@@ -31,8 +35,8 @@
             <td>{{ object.label|placeholder }}</td>
             <td>{{ object.label|placeholder }}</td>
           </tr>
           </tr>
           <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>
           <tr>
           <tr>
             <th scope="row">{% trans "Description" %}</th>
             <th scope="row">{% trans "Description" %}</th>

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

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

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

@@ -39,6 +39,9 @@
         {% if perms.dcim.add_rearporttemplate %}
         {% 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>
           <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 %}
         {% 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>
       </ul>
     </div>
     </div>
   {% endif %}
   {% endif %}