فهرست منبع

Add ModuleBay and ModuleBayTemplate models

jeremystretch 4 سال پیش
والد
کامیت
e529d7fd3b
33فایلهای تغییر یافته به همراه1008 افزوده شده و 21 حذف شده
  1. 19 0
      netbox/dcim/api/nested_serializers.py
  2. 22 0
      netbox/dcim/api/serializers.py
  3. 2 0
      netbox/dcim/api/urls.py
  4. 15 2
      netbox/dcim/api/views.py
  5. 30 0
      netbox/dcim/filtersets.py
  6. 6 0
      netbox/dcim/forms/bulk_create.py
  7. 33 1
      netbox/dcim/forms/bulk_edit.py
  8. 12 0
      netbox/dcim/forms/bulk_import.py
  9. 11 0
      netbox/dcim/forms/filtersets.py
  10. 29 0
      netbox/dcim/forms/models.py
  11. 11 0
      netbox/dcim/forms/object_create.py
  12. 10 0
      netbox/dcim/forms/object_import.py
  13. 6 0
      netbox/dcim/graphql/schema.py
  14. 18 0
      netbox/dcim/graphql/types.py
  15. 53 0
      netbox/dcim/migrations/0145_modules.py
  16. 2 0
      netbox/dcim/models/__init__.py
  17. 19 1
      netbox/dcim/models/device_component_templates.py
  18. 19 11
      netbox/dcim/models/device_components.py
  19. 3 0
      netbox/dcim/models/devices.py
  20. 33 2
      netbox/dcim/tables/devices.py
  21. 15 1
      netbox/dcim/tables/devicetypes.py
  22. 79 0
      netbox/dcim/tests/test_api.py
  23. 155 0
      netbox/dcim/tests/test_filtersets.py
  24. 10 0
      netbox/dcim/tests/test_models.py
  25. 109 0
      netbox/dcim/tests/test_views.py
  26. 23 0
      netbox/dcim/urls.py
  27. 118 3
      netbox/dcim/views.py
  28. 1 0
      netbox/netbox/navigation_menu.py
  29. 15 0
      netbox/templates/dcim/device/base.html
  30. 43 0
      netbox/templates/dcim/device/modulebays.html
  31. 7 0
      netbox/templates/dcim/device_list.html
  32. 11 0
      netbox/templates/dcim/devicetype/base.html
  33. 69 0
      netbox/templates/dcim/modulebay.html

+ 19 - 0
netbox/dcim/api/nested_serializers.py

@@ -20,6 +20,8 @@ __all__ = [
     'NestedInterfaceTemplateSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInventoryItemSerializer',
     'NestedInventoryItemSerializer',
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
+    'NestedModuleBaySerializer',
+    'NestedModuleBayTemplateSerializer',
     'NestedPlatformSerializer',
     'NestedPlatformSerializer',
     'NestedPowerFeedSerializer',
     'NestedPowerFeedSerializer',
     'NestedPowerOutletSerializer',
     'NestedPowerOutletSerializer',
@@ -195,6 +197,14 @@ class NestedFrontPortTemplateSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class NestedModuleBayTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
+
+    class Meta:
+        model = models.ModuleBayTemplate
+        fields = ['id', 'url', 'display', 'name']
+
+
 class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
 class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
 
 
@@ -298,6 +308,15 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
         fields = ['id', 'url', 'display', 'device', 'name', 'cable', '_occupied']
 
 
 
 
+class NestedModuleBaySerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
+    # module = NestedModuleSerializer(read_only=True)
+
+    class Meta:
+        model = models.DeviceBay
+        fields = ['id', 'url', 'display', 'name']
+
+
 class NestedDeviceBaySerializer(WritableNestedSerializer):
 class NestedDeviceBaySerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer(read_only=True)
     device = NestedDeviceSerializer(read_only=True)

+ 22 - 0
netbox/dcim/api/serializers.py

@@ -409,6 +409,15 @@ class FrontPortTemplateSerializer(ValidatedModelSerializer):
         ]
         ]
 
 
 
 
+class ModuleBayTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebaytemplate-detail')
+    device_type = NestedDeviceTypeSerializer()
+
+    class Meta:
+        model = ModuleBayTemplate
+        fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
+
+
 class DeviceBayTemplateSerializer(ValidatedModelSerializer):
 class DeviceBayTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebaytemplate-detail')
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()
@@ -707,6 +716,19 @@ class FrontPortSerializer(PrimaryModelSerializer, LinkTerminationSerializer):
         ]
         ]
 
 
 
 
+class ModuleBaySerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
+    device = NestedDeviceSerializer()
+    # installed_module = NestedModuleSerializer(required=False, allow_null=True)
+
+    class Meta:
+        model = ModuleBay
+        fields = [
+            'id', 'url', 'display', 'device', 'name', 'label', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated',
+        ]
+
+
 class DeviceBaySerializer(PrimaryModelSerializer):
 class DeviceBaySerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:devicebay-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()

+ 2 - 0
netbox/dcim/api/urls.py

@@ -28,6 +28,7 @@ router.register('power-outlet-templates', views.PowerOutletTemplateViewSet)
 router.register('interface-templates', views.InterfaceTemplateViewSet)
 router.register('interface-templates', views.InterfaceTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
+router.register('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 
 
 # Devices
 # Devices
@@ -43,6 +44,7 @@ router.register('power-outlets', views.PowerOutletViewSet)
 router.register('interfaces', views.InterfaceViewSet)
 router.register('interfaces', views.InterfaceViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('front-ports', views.FrontPortViewSet)
 router.register('rear-ports', views.RearPortViewSet)
 router.register('rear-ports', views.RearPortViewSet)
+router.register('module-bays', views.ModuleBayViewSet)
 router.register('device-bays', views.DeviceBayViewSet)
 router.register('device-bays', views.DeviceBayViewSet)
 router.register('inventory-items', views.InventoryItemViewSet)
 router.register('inventory-items', views.InventoryItemViewSet)
 
 

+ 15 - 2
netbox/dcim/api/views.py

@@ -329,6 +329,12 @@ class RearPortTemplateViewSet(ModelViewSet):
     filterset_class = filtersets.RearPortTemplateFilterSet
     filterset_class = filtersets.RearPortTemplateFilterSet
 
 
 
 
+class ModuleBayTemplateViewSet(ModelViewSet):
+    queryset = ModuleBayTemplate.objects.prefetch_related('device_type__manufacturer')
+    serializer_class = serializers.ModuleBayTemplateSerializer
+    filterset_class = filtersets.ModuleBayTemplateFilterSet
+
+
 class DeviceBayTemplateViewSet(ModelViewSet):
 class DeviceBayTemplateViewSet(ModelViewSet):
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     queryset = DeviceBayTemplate.objects.prefetch_related('device_type__manufacturer')
     serializer_class = serializers.DeviceBayTemplateSerializer
     serializer_class = serializers.DeviceBayTemplateSerializer
@@ -569,15 +575,22 @@ class RearPortViewSet(PassThroughPortMixin, ModelViewSet):
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
+class ModuleBayViewSet(ModelViewSet):
+    queryset = ModuleBay.objects.prefetch_related('tags')
+    serializer_class = serializers.ModuleBaySerializer
+    filterset_class = filtersets.ModuleBayFilterSet
+    brief_prefetch_fields = ['device']
+
+
 class DeviceBayViewSet(ModelViewSet):
 class DeviceBayViewSet(ModelViewSet):
-    queryset = DeviceBay.objects.prefetch_related('installed_device').prefetch_related('tags')
+    queryset = DeviceBay.objects.prefetch_related('installed_device', 'tags')
     serializer_class = serializers.DeviceBaySerializer
     serializer_class = serializers.DeviceBaySerializer
     filterset_class = filtersets.DeviceBayFilterSet
     filterset_class = filtersets.DeviceBayFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
 class InventoryItemViewSet(ModelViewSet):
 class InventoryItemViewSet(ModelViewSet):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer').prefetch_related('tags')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'tags')
     serializer_class = serializers.InventoryItemSerializer
     serializer_class = serializers.InventoryItemSerializer
     filterset_class = filtersets.InventoryItemFilterSet
     filterset_class = filtersets.InventoryItemFilterSet
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']

+ 30 - 0
netbox/dcim/filtersets.py

@@ -41,6 +41,8 @@ __all__ = (
     'InventoryItemFilterSet',
     'InventoryItemFilterSet',
     'LocationFilterSet',
     'LocationFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
+    'ModuleBayFilterSet',
+    'ModuleBayTemplateFilterSet',
     'PathEndpointFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PlatformFilterSet',
     'PowerConnectionFilterSet',
     'PowerConnectionFilterSet',
@@ -447,6 +449,10 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
         method='_pass_through_ports',
         method='_pass_through_ports',
         label='Has pass-through ports',
         label='Has pass-through ports',
     )
     )
+    module_bays = django_filters.BooleanFilter(
+        method='_module_bays',
+        label='Has module bays',
+    )
     device_bays = django_filters.BooleanFilter(
     device_bays = django_filters.BooleanFilter(
         method='_device_bays',
         method='_device_bays',
         label='Has device bays',
         label='Has device bays',
@@ -490,6 +496,9 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
             rearporttemplates__isnull=value
             rearporttemplates__isnull=value
         )
         )
 
 
+    def _module_bays(self, queryset, name, value):
+        return queryset.exclude(modulebaytemplates__isnull=value)
+
     def _device_bays(self, queryset, name, value):
     def _device_bays(self, queryset, name, value):
         return queryset.exclude(devicebaytemplates__isnull=value)
         return queryset.exclude(devicebaytemplates__isnull=value)
 
 
@@ -576,6 +585,13 @@ class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentF
         fields = ['id', 'name', 'type', 'color', 'positions']
         fields = ['id', 'name', 'type', 'color', 'positions']
 
 
 
 
+class ModuleBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+
+    class Meta:
+        model = ModuleBayTemplate
+        fields = ['id', 'name']
+
+
 class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
@@ -760,6 +776,10 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
         method='_pass_through_ports',
         method='_pass_through_ports',
         label='Has pass-through ports',
         label='Has pass-through ports',
     )
     )
+    module_bays = django_filters.BooleanFilter(
+        method='_module_bays',
+        label='Has module bays',
+    )
     device_bays = django_filters.BooleanFilter(
     device_bays = django_filters.BooleanFilter(
         method='_device_bays',
         method='_device_bays',
         label='Has device bays',
         label='Has device bays',
@@ -811,6 +831,9 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
             rearports__isnull=value
             rearports__isnull=value
         )
         )
 
 
+    def _module_bays(self, queryset, name, value):
+        return queryset.exclude(modulebays__isnull=value)
+
     def _device_bays(self, queryset, name, value):
     def _device_bays(self, queryset, name, value):
         return queryset.exclude(devicebays__isnull=value)
         return queryset.exclude(devicebays__isnull=value)
 
 
@@ -1104,6 +1127,13 @@ class RearPortFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet, CableTe
         fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
         fields = ['id', 'name', 'label', 'type', 'color', 'positions', 'description']
 
 
 
 
+class ModuleBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
+
+    class Meta:
+        model = ModuleBay
+        fields = ['id', 'name', 'label', 'description']
+
+
 class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
 class DeviceBayFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
 
 
     class Meta:
     class Meta:

+ 6 - 0
netbox/dcim/forms/bulk_create.py

@@ -13,6 +13,7 @@ __all__ = (
     # 'FrontPortBulkCreateForm',
     # 'FrontPortBulkCreateForm',
     'InterfaceBulkCreateForm',
     'InterfaceBulkCreateForm',
     'InventoryItemBulkCreateForm',
     'InventoryItemBulkCreateForm',
+    'ModuleBayBulkCreateForm',
     'PowerOutletBulkCreateForm',
     'PowerOutletBulkCreateForm',
     'PowerPortBulkCreateForm',
     'PowerPortBulkCreateForm',
     'RearPortBulkCreateForm',
     'RearPortBulkCreateForm',
@@ -95,6 +96,11 @@ class RearPortBulkCreateForm(
     field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
     field_order = ('name_pattern', 'label_pattern', 'type', 'positions', 'mark_connected', 'description', 'tags')
 
 
 
 
+class ModuleBayBulkCreateForm(DeviceBulkAddComponentForm):
+    model = ModuleBay
+    field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
+
+
 class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
 class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
     model = DeviceBay
     model = DeviceBay
     field_order = ('name_pattern', 'label_pattern', 'description', 'tags')
     field_order = ('name_pattern', 'label_pattern', 'description', 'tags')

+ 33 - 1
netbox/dcim/forms/bulk_edit.py

@@ -7,7 +7,6 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from dcim.models import *
 from dcim.models import *
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
 from extras.forms import AddRemoveTagsForm, CustomFieldModelBulkEditForm
-from ipam.constants import BGP_ASN_MIN, BGP_ASN_MAX
 from ipam.models import VLAN, ASN
 from ipam.models import VLAN, ASN
 from tenancy.models import Tenant
 from tenancy.models import Tenant
 from utilities.forms import (
 from utilities.forms import (
@@ -33,6 +32,8 @@ __all__ = (
     'InventoryItemBulkEditForm',
     'InventoryItemBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
+    'ModuleBayBulkEditForm',
+    'ModuleBayTemplateBulkEditForm',
     'PlatformBulkEditForm',
     'PlatformBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerOutletBulkEditForm',
     'PowerOutletBulkEditForm',
@@ -823,6 +824,23 @@ class RearPortTemplateBulkEditForm(BulkEditForm):
         nullable_fields = ('description',)
         nullable_fields = ('description',)
 
 
 
 
+class ModuleBayTemplateBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ModuleBayTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ('label', 'description')
+
+
 class DeviceBayTemplateBulkEditForm(BulkEditForm):
 class DeviceBayTemplateBulkEditForm(BulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=DeviceBayTemplate.objects.all(),
         queryset=DeviceBayTemplate.objects.all(),
@@ -1076,6 +1094,20 @@ class RearPortBulkEditForm(
         nullable_fields = ['label', 'description']
         nullable_fields = ['label', 'description']
 
 
 
 
+class ModuleBayBulkEditForm(
+    form_from_model(DeviceBay, ['label', 'description']),
+    AddRemoveTagsForm,
+    CustomFieldModelBulkEditForm
+):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ModuleBay.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'description']
+
+
 class DeviceBayBulkEditForm(
 class DeviceBayBulkEditForm(
     form_from_model(DeviceBay, ['label', 'description']),
     form_from_model(DeviceBay, ['label', 'description']),
     AddRemoveTagsForm,
     AddRemoveTagsForm,

+ 12 - 0
netbox/dcim/forms/bulk_import.py

@@ -26,6 +26,7 @@ __all__ = (
     'InventoryItemCSVForm',
     'InventoryItemCSVForm',
     'LocationCSVForm',
     'LocationCSVForm',
     'ManufacturerCSVForm',
     'ManufacturerCSVForm',
+    'ModuleBayCSVForm',
     'PlatformCSVForm',
     'PlatformCSVForm',
     'PowerFeedCSVForm',
     'PowerFeedCSVForm',
     'PowerOutletCSVForm',
     'PowerOutletCSVForm',
@@ -678,6 +679,17 @@ class RearPortCSVForm(CustomFieldModelCSVForm):
         }
         }
 
 
 
 
+class ModuleBayCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = ModuleBay
+        fields = ('device', 'name', 'label', 'description')
+
+
 class DeviceBayCSVForm(CustomFieldModelCSVForm):
 class DeviceBayCSVForm(CustomFieldModelCSVForm):
     device = CSVModelChoiceField(
     device = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),

+ 11 - 0
netbox/dcim/forms/filtersets.py

@@ -29,6 +29,7 @@ __all__ = (
     'InventoryItemFilterForm',
     'InventoryItemFilterForm',
     'LocationFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
+    'ModuleBayFilterForm',
     'PlatformFilterForm',
     'PlatformFilterForm',
     'PowerConnectionFilterForm',
     'PowerConnectionFilterForm',
     'PowerFeedFilterForm',
     'PowerFeedFilterForm',
@@ -970,6 +971,16 @@ class RearPortFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+class ModuleBayFilterForm(DeviceComponentFilterForm):
+    model = ModuleBay
+    field_groups = [
+        ['q', 'tag'],
+        ['name', 'label'],
+        ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
+    ]
+    tag = TagFilterField(model)
+
+
 class DeviceBayFilterForm(DeviceComponentFilterForm):
 class DeviceBayFilterForm(DeviceComponentFilterForm):
     model = DeviceBay
     model = DeviceBay
     field_groups = [
     field_groups = [

+ 29 - 0
netbox/dcim/forms/models.py

@@ -39,6 +39,8 @@ __all__ = (
     'InventoryItemForm',
     'InventoryItemForm',
     'LocationForm',
     'LocationForm',
     'ManufacturerForm',
     'ManufacturerForm',
+    'ModuleBayForm',
+    'ModuleBayTemplateForm',
     'PlatformForm',
     'PlatformForm',
     'PopulateDeviceBayForm',
     'PopulateDeviceBayForm',
     'PowerFeedForm',
     'PowerFeedForm',
@@ -984,6 +986,17 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
+class ModuleBayTemplateForm(BootstrapMixin, forms.ModelForm):
+    class Meta:
+        model = ModuleBayTemplate
+        fields = [
+            'device_type', 'name', 'label', 'description',
+        ]
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
 class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
 class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = DeviceBayTemplate
         model = DeviceBayTemplate
@@ -1222,6 +1235,22 @@ class RearPortForm(CustomFieldModelForm):
         }
         }
 
 
 
 
+class ModuleBayForm(CustomFieldModelForm):
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ModuleBay
+        fields = [
+            'device', 'name', 'label', 'description', 'tags',
+        ]
+        widgets = {
+            'device': forms.HiddenInput(),
+        }
+
+
 class DeviceBayForm(CustomFieldModelForm):
 class DeviceBayForm(CustomFieldModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),

+ 11 - 0
netbox/dcim/forms/object_create.py

@@ -25,6 +25,8 @@ __all__ = (
     'InterfaceCreateForm',
     'InterfaceCreateForm',
     'InterfaceTemplateCreateForm',
     'InterfaceTemplateCreateForm',
     'InventoryItemCreateForm',
     'InventoryItemCreateForm',
+    'ModuleBayCreateForm',
+    'ModuleBayTemplateCreateForm',
     'PowerOutletCreateForm',
     'PowerOutletCreateForm',
     'PowerOutletTemplateCreateForm',
     'PowerOutletTemplateCreateForm',
     'PowerPortCreateForm',
     'PowerPortCreateForm',
@@ -327,6 +329,10 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
     )
     )
 
 
 
 
+class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
+    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
+
+
 class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
 class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
     field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
     field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
 
 
@@ -619,6 +625,11 @@ class RearPortCreateForm(ComponentCreateForm):
     )
     )
 
 
 
 
+class ModuleBayCreateForm(ComponentCreateForm):
+    model = ModuleBay
+    field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
+
+
 class DeviceBayCreateForm(ComponentCreateForm):
 class DeviceBayCreateForm(ComponentCreateForm):
     model = DeviceBay
     model = DeviceBay
     field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
     field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')

+ 10 - 0
netbox/dcim/forms/object_import.py

@@ -11,6 +11,7 @@ __all__ = (
     'DeviceTypeImportForm',
     'DeviceTypeImportForm',
     'FrontPortTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
+    'ModuleBayTemplateImportForm',
     'PowerOutletTemplateImportForm',
     'PowerOutletTemplateImportForm',
     'PowerPortTemplateImportForm',
     'PowerPortTemplateImportForm',
     'RearPortTemplateImportForm',
     'RearPortTemplateImportForm',
@@ -139,6 +140,15 @@ class RearPortTemplateImportForm(ComponentTemplateImportForm):
         ]
         ]
 
 
 
 
+class ModuleBayTemplateImportForm(ComponentTemplateImportForm):
+
+    class Meta:
+        model = ModuleBayTemplate
+        fields = [
+            'device_type', 'name', 'label', 'description',
+        ]
+
+
 class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
 class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
 
 
     class Meta:
     class Meta:

+ 6 - 0
netbox/dcim/graphql/schema.py

@@ -56,6 +56,12 @@ class DCIMQuery(graphene.ObjectType):
     manufacturer = ObjectField(ManufacturerType)
     manufacturer = ObjectField(ManufacturerType)
     manufacturer_list = ObjectListField(ManufacturerType)
     manufacturer_list = ObjectListField(ManufacturerType)
 
 
+    module_bay = ObjectField(ModuleBayType)
+    module_bay_list = ObjectListField(ModuleBayType)
+
+    module_bay_template = ObjectField(ModuleBayTemplateType)
+    module_bay_template_list = ObjectListField(ModuleBayTemplateType)
+
     platform = ObjectField(PlatformType)
     platform = ObjectField(PlatformType)
     platform_list = ObjectListField(PlatformType)
     platform_list = ObjectListField(PlatformType)
 
 

+ 18 - 0
netbox/dcim/graphql/types.py

@@ -27,6 +27,8 @@ __all__ = (
     'InventoryItemType',
     'InventoryItemType',
     'LocationType',
     'LocationType',
     'ManufacturerType',
     'ManufacturerType',
+    'ModuleBayType',
+    'ModuleBayTemplateType',
     'PlatformType',
     'PlatformType',
     'PowerFeedType',
     'PowerFeedType',
     'PowerOutletType',
     'PowerOutletType',
@@ -254,6 +256,22 @@ class ManufacturerType(OrganizationalObjectType):
         filterset_class = filtersets.ManufacturerFilterSet
         filterset_class = filtersets.ManufacturerFilterSet
 
 
 
 
+class ModuleBayType(ComponentObjectType):
+
+    class Meta:
+        model = models.ModuleBay
+        fields = '__all__'
+        filterset_class = filtersets.ModuleBayFilterSet
+
+
+class ModuleBayTemplateType(ComponentTemplateObjectType):
+
+    class Meta:
+        model = models.ModuleBayTemplate
+        fields = '__all__'
+        filterset_class = filtersets.ModuleBayTemplateFilterSet
+
+
 class PlatformType(OrganizationalObjectType):
 class PlatformType(OrganizationalObjectType):
 
 
     class Meta:
     class Meta:

+ 53 - 0
netbox/dcim/migrations/0145_modules.py

@@ -0,0 +1,53 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.fields
+import utilities.ordering
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0066_customfield_name_validation'),
+        ('dcim', '0144_site_remove_deprecated_fields'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='ModuleBayTemplate',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
+                ('label', models.CharField(blank=True, max_length=64)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebaytemplates', to='dcim.devicetype')),
+            ],
+            options={
+                'ordering': ('device_type', '_name'),
+                'unique_together': {('device_type', 'name')},
+            },
+        ),
+        migrations.CreateModel(
+            name='ModuleBay',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
+                ('label', models.CharField(blank=True, max_length=64)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('device', '_name'),
+                'unique_together': {('device', 'name')},
+            },
+        ),
+    ]

+ 2 - 0
netbox/dcim/models/__init__.py

@@ -27,6 +27,8 @@ __all__ = (
     'InventoryItem',
     'InventoryItem',
     'Location',
     'Location',
     'Manufacturer',
     'Manufacturer',
+    'ModuleBay',
+    'ModuleBayTemplate',
     'Platform',
     'Platform',
     'PowerFeed',
     'PowerFeed',
     'PowerOutlet',
     'PowerOutlet',

+ 19 - 1
netbox/dcim/models/device_component_templates.py

@@ -9,7 +9,7 @@ from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from .device_components import (
 from .device_components import (
-    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, PowerOutlet, PowerPort, RearPort,
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort,
 )
 )
 
 
 
 
@@ -19,6 +19,7 @@ __all__ = (
     'DeviceBayTemplate',
     'DeviceBayTemplate',
     'FrontPortTemplate',
     'FrontPortTemplate',
     'InterfaceTemplate',
     'InterfaceTemplate',
+    'ModuleBayTemplate',
     'PowerOutletTemplate',
     'PowerOutletTemplate',
     'PowerPortTemplate',
     'PowerPortTemplate',
     'RearPortTemplate',
     'RearPortTemplate',
@@ -360,6 +361,23 @@ class RearPortTemplate(ComponentTemplateModel):
         )
         )
 
 
 
 
+@extras_features('webhooks')
+class ModuleBayTemplate(ComponentTemplateModel):
+    """
+    A template for a ModuleBay to be created for a new parent Device.
+    """
+    class Meta:
+        ordering = ('device_type', '_name')
+        unique_together = ('device_type', 'name')
+
+    def instantiate(self, device):
+        return ModuleBay(
+            device=device,
+            name=self.name,
+            label=self.label
+        )
+
+
 @extras_features('webhooks')
 @extras_features('webhooks')
 class DeviceBayTemplate(ComponentTemplateModel):
 class DeviceBayTemplate(ComponentTemplateModel):
     """
     """

+ 19 - 11
netbox/dcim/models/device_components.py

@@ -30,6 +30,7 @@ __all__ = (
     'FrontPort',
     'FrontPort',
     'Interface',
     'Interface',
     'InventoryItem',
     'InventoryItem',
+    'ModuleBay',
     'PathEndpoint',
     'PathEndpoint',
     'PowerOutlet',
     'PowerOutlet',
     'PowerPort',
     'PowerPort',
@@ -229,7 +230,7 @@ class PathEndpoint(models.Model):
 
 
 
 
 #
 #
-# Console ports
+# Console components
 #
 #
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
@@ -260,10 +261,6 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
         return reverse('dcim:consoleport', kwargs={'pk': self.pk})
         return reverse('dcim:consoleport', kwargs={'pk': self.pk})
 
 
 
 
-#
-# Console server ports
-#
-
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
 class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
     """
     """
@@ -293,7 +290,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
 
 
 
 
 #
 #
-# Power ports
+# Power components
 #
 #
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
@@ -389,10 +386,6 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
         }
         }
 
 
 
 
-#
-# Power outlets
-#
-
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
 class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
     """
     """
@@ -866,9 +859,24 @@ class RearPort(ComponentModel, LinkTermination):
 
 
 
 
 #
 #
-# Device bays
+# Bays
 #
 #
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class ModuleBay(ComponentModel):
+    """
+    An empty space within a Device which can house a child device
+    """
+    clone_fields = ['device']
+
+    class Meta:
+        ordering = ('device', '_name')
+        unique_together = ('device', 'name')
+
+    def get_absolute_url(self):
+        return reverse('dcim:modulebay', kwargs={'pk': self.pk})
+
+
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class DeviceBay(ComponentModel):
 class DeviceBay(ComponentModel):
     """
     """

+ 3 - 0
netbox/dcim/models/devices.py

@@ -786,6 +786,9 @@ class Device(PrimaryModel, ConfigContextModel):
             FrontPort.objects.bulk_create(
             FrontPort.objects.bulk_create(
                 [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
                 [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
             )
             )
+            ModuleBay.objects.bulk_create(
+                [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()]
+            )
             DeviceBay.objects.bulk_create(
             DeviceBay.objects.bulk_create(
                 [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
                 [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
             )
             )

+ 33 - 2
netbox/dcim/tables/devices.py

@@ -2,8 +2,8 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, Platform,
-    PowerOutlet, PowerPort, RearPort, VirtualChassis,
+    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay,
+    Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
 )
 )
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
@@ -25,6 +25,7 @@ __all__ = (
     'DeviceImportTable',
     'DeviceImportTable',
     'DeviceInterfaceTable',
     'DeviceInterfaceTable',
     'DeviceInventoryItemTable',
     'DeviceInventoryItemTable',
+    'DeviceModuleBayTable',
     'DevicePowerPortTable',
     'DevicePowerPortTable',
     'DevicePowerOutletTable',
     'DevicePowerOutletTable',
     'DeviceRearPortTable',
     'DeviceRearPortTable',
@@ -33,6 +34,7 @@ __all__ = (
     'FrontPortTable',
     'FrontPortTable',
     'InterfaceTable',
     'InterfaceTable',
     'InventoryItemTable',
     'InventoryItemTable',
+    'ModuleBayTable',
     'PlatformTable',
     'PlatformTable',
     'PowerOutletTable',
     'PowerOutletTable',
     'PowerPortTable',
     'PowerPortTable',
@@ -716,6 +718,35 @@ class DeviceDeviceBayTable(DeviceBayTable):
         )
         )
 
 
 
 
+class ModuleBayTable(DeviceComponentTable):
+    device = tables.Column(
+        linkify={
+            'viewname': 'dcim:device_modulebays',
+            'args': [Accessor('device_id')],
+        }
+    )
+    tags = TagColumn(
+        url_name='dcim:modulebay_list'
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = ModuleBay
+        fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags')
+        default_columns = ('pk', 'name', 'device', 'label', 'description')
+
+
+class DeviceModuleBayTable(ModuleBayTable):
+    actions = ButtonsColumn(
+        model=ModuleBay,
+        buttons=('edit', 'delete')
+    )
+
+    class Meta(DeviceComponentTable.Meta):
+        model = ModuleBay
+        fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions')
+        default_columns = ('pk', 'name', 'label', 'description', 'actions')
+
+
 class InventoryItemTable(DeviceComponentTable):
 class InventoryItemTable(DeviceComponentTable):
     device = tables.Column(
     device = tables.Column(
         linkify={
         linkify={

+ 15 - 1
netbox/dcim/tables/devicetypes.py

@@ -2,7 +2,7 @@ import django_tables2 as tables
 
 
 from dcim.models import (
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
-    Manufacturer, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 )
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
@@ -16,6 +16,7 @@ __all__ = (
     'FrontPortTemplateTable',
     'FrontPortTemplateTable',
     'InterfaceTemplateTable',
     'InterfaceTemplateTable',
     'ManufacturerTable',
     'ManufacturerTable',
+    'ModuleBayTemplateTable',
     'PowerOutletTemplateTable',
     'PowerOutletTemplateTable',
     'PowerPortTemplateTable',
     'PowerPortTemplateTable',
     'RearPortTemplateTable',
     'RearPortTemplateTable',
@@ -207,6 +208,19 @@ class RearPortTemplateTable(ComponentTemplateTable):
         empty_text = "None"
         empty_text = "None"
 
 
 
 
+class ModuleBayTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=ModuleBayTemplate,
+        buttons=('edit', 'delete'),
+        return_url_extra='%23tab_modulebays'
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = ModuleBayTemplate
+        fields = ('pk', 'name', 'label', 'description', 'actions')
+        empty_text = "None"
+
+
 class DeviceBayTemplateTable(ComponentTemplateTable):
 class DeviceBayTemplateTable(ComponentTemplateTable):
     actions = ButtonsColumn(
     actions = ButtonsColumn(
         model=DeviceBayTemplate,
         model=DeviceBayTemplate,

+ 79 - 0
netbox/dcim/tests/test_api.py

@@ -778,6 +778,46 @@ class RearPortTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
+class ModuleBayTemplateTest(APIViewTestCases.APIViewTestCase):
+    model = ModuleBayTemplate
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Device Type 1',
+            slug='device-type-1',
+            subdevice_role=SubdeviceRoleChoices.ROLE_PARENT
+        )
+
+        module_bay_templates = (
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 1'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 2'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay Template 3'),
+        )
+        ModuleBayTemplate.objects.bulk_create(module_bay_templates)
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Module Bay Template 4',
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Module Bay Template 5',
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Module Bay Template 6',
+            },
+        ]
+
+
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
 class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']
@@ -1369,6 +1409,45 @@ class RearPortTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
+class ModuleBayTest(APIViewTestCases.APIViewTestCase):
+    model = ModuleBay
+    brief_fields = ['display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        site = Site.objects.create(name='Site 1', slug='site-1')
+        devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
+
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1')
+        device = Device.objects.create(device_type=device_type, device_role=devicerole, name='Device 1', site=site)
+
+        device_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)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'name': 'Device Bay 4',
+            },
+            {
+                'device': device.pk,
+                'name': 'Device Bay 5',
+            },
+            {
+                'device': device.pk,
+                'name': 'Device Bay 6',
+            },
+        ]
+
+
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
 class DeviceBayTest(APIViewTestCases.APIViewTestCase):
     model = DeviceBay
     model = DeviceBay
     brief_fields = ['device', 'display', 'id', 'name', 'url']
     brief_fields = ['device', 'display', 'id', 'name', 'url']

+ 155 - 0
netbox/dcim/tests/test_filtersets.py

@@ -678,6 +678,10 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
             FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
             FrontPortTemplate(device_type=device_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
             FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
             FrontPortTemplate(device_type=device_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
         ))
         ))
+        ModuleBayTemplate.objects.bulk_create((
+            ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
+            ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
+        ))
         DeviceBayTemplate.objects.bulk_create((
         DeviceBayTemplate.objects.bulk_create((
             DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'),
             DeviceBayTemplate(device_type=device_types[0], name='Device Bay 1'),
             DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
             DeviceBayTemplate(device_type=device_types[1], name='Device Bay 2'),
@@ -762,6 +766,12 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'device_bays': 'false'}
         params = {'device_bays': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+    def test_module_bays(self):
+        params = {'module_bays': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'module_bays': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
 
 
 class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class ConsolePortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePortTemplate.objects.all()
     queryset = ConsolePortTemplate.objects.all()
@@ -1036,6 +1046,38 @@ class RearPortTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
+class ModuleBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ModuleBayTemplate.objects.all()
+    filterset = ModuleBayTemplateFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+
+        device_types = (
+            DeviceType(manufacturer=manufacturer, model='Model 1', slug='model-1'),
+            DeviceType(manufacturer=manufacturer, model='Model 2', slug='model-2'),
+            DeviceType(manufacturer=manufacturer, model='Model 3', slug='model-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        ModuleBayTemplate.objects.bulk_create((
+            ModuleBayTemplate(device_type=device_types[0], name='Module Bay 1'),
+            ModuleBayTemplate(device_type=device_types[1], name='Module Bay 2'),
+            ModuleBayTemplate(device_type=device_types[2], name='Module Bay 3'),
+        ))
+
+    def test_name(self):
+        params = {'name': ['Module Bay 1', 'Module Bay 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_devicetype_id(self):
+        device_types = DeviceType.objects.all()[:2]
+        params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBayTemplate.objects.all()
     queryset = DeviceBayTemplate.objects.all()
     filterset = DeviceBayTemplateFilterSet
     filterset = DeviceBayTemplateFilterSet
@@ -1280,6 +1322,10 @@ 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'),
+        ))
         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'),
@@ -1465,6 +1511,12 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'pass_through_ports': 'false'}
         params = {'pass_through_ports': 'false'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
+    def test_module_bays(self):
+        params = {'module_bays': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'module_bays': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
     def test_device_bays(self):
     def test_device_bays(self):
         params = {'device_bays': 'true'}
         params = {'device_bays': 'true'}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
@@ -2508,6 +2560,109 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
+class ModuleBayTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ModuleBay.objects.all()
+    filterset = ModuleBayFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        regions = (
+            Region(name='Region 1', slug='region-1'),
+            Region(name='Region 2', slug='region-2'),
+            Region(name='Region 3', slug='region-3'),
+        )
+        for region in regions:
+            region.save()
+
+        groups = (
+            SiteGroup(name='Site Group 1', slug='site-group-1'),
+            SiteGroup(name='Site Group 2', slug='site-group-2'),
+            SiteGroup(name='Site Group 3', slug='site-group-3'),
+        )
+        for group in groups:
+            group.save()
+
+        sites = Site.objects.bulk_create((
+            Site(name='Site 1', slug='site-1', region=regions[0], group=groups[0]),
+            Site(name='Site 2', slug='site-2', region=regions[1], group=groups[1]),
+            Site(name='Site 3', slug='site-3', region=regions[2], group=groups[2]),
+            Site(name='Site X', slug='site-x'),
+        ))
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        device_type = DeviceType.objects.create(manufacturer=manufacturer, model='Model 1', slug='model-1')
+        device_role = DeviceRole.objects.create(name='Device Role 1', slug='device-role-1')
+
+        locations = (
+            Location(name='Location 1', slug='location-1', site=sites[0]),
+            Location(name='Location 2', slug='location-2', site=sites[1]),
+            Location(name='Location 3', slug='location-3', site=sites[2]),
+        )
+        for location in locations:
+            location.save()
+
+        devices = (
+            Device(name='Device 1', device_type=device_type, device_role=device_role, site=sites[0], location=locations[0]),
+            Device(name='Device 2', device_type=device_type, device_role=device_role, site=sites[1], location=locations[1]),
+            Device(name='Device 3', device_type=device_type, device_role=device_role, site=sites[2], location=locations[2]),
+        )
+        Device.objects.bulk_create(devices)
+
+        module_bays = (
+            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.objects.bulk_create(module_bays)
+
+    def test_name(self):
+        params = {'name': ['Module Bay 1', 'Module Bay 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_label(self):
+        params = {'label': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_description(self):
+        params = {'description': ['First', 'Second']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_region(self):
+        regions = Region.objects.all()[:2]
+        params = {'region_id': [regions[0].pk, regions[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'region': [regions[0].slug, regions[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site_group(self):
+        site_groups = SiteGroup.objects.all()[:2]
+        params = {'site_group_id': [site_groups[0].pk, site_groups[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site_group': [site_groups[0].slug, site_groups[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_site(self):
+        sites = Site.objects.all()[:2]
+        params = {'site_id': [sites[0].pk, sites[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'site': [sites[0].slug, sites[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_location(self):
+        locations = Location.objects.all()[:2]
+        params = {'location_id': [locations[0].pk, locations[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'location': [locations[0].slug, locations[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_device(self):
+        devices = Device.objects.all()[:2]
+        params = {'device_id': [devices[0].pk, devices[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'device': [devices[0].name, devices[1].name]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceBayTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceBay.objects.all()
     queryset = DeviceBay.objects.all()
     filterset = DeviceBayFilterSet
     filterset = DeviceBayFilterSet

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

@@ -308,6 +308,11 @@ class DeviceTestCase(TestCase):
             rear_port_position=2
             rear_port_position=2
         ).save()
         ).save()
 
 
+        ModuleBayTemplate(
+            device_type=self.device_type,
+            name='Module Bay 1'
+        ).save()
+
         DeviceBayTemplate(
         DeviceBayTemplate(
             device_type=self.device_type,
             device_type=self.device_type,
             name='Device Bay 1'
             name='Device Bay 1'
@@ -371,6 +376,11 @@ class DeviceTestCase(TestCase):
             rear_port_position=2
             rear_port_position=2
         )
         )
 
 
+        ModuleBay.objects.get(
+            device=d,
+            name='Module Bay 1'
+        )
+
         DeviceBay.objects.get(
         DeviceBay.objects.get(
             device=d,
             device=d,
             name='Device Bay 1'
             name='Device Bay 1'

+ 109 - 0
netbox/dcim/tests/test_views.py

@@ -554,6 +554,19 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_frontports', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_devicetype_modulebays(self):
+        devicetype = DeviceType.objects.first()
+        module_bays = (
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay 1'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay 2'),
+            ModuleBayTemplate(device_type=devicetype, name='Module Bay 3'),
+        )
+        ModuleBayTemplate.objects.bulk_create(module_bays)
+
+        url = reverse('dcim:devicetype_modulebays', kwargs={'pk': devicetype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_devicetype_devicebays(self):
     def test_devicetype_devicebays(self):
         devicetype = DeviceType.objects.first()
         devicetype = DeviceType.objects.first()
@@ -638,6 +651,10 @@ front-ports:
   - name: Front Port 3
   - name: Front Port 3
     type: 8p8c
     type: 8p8c
     rear_port: Rear Port 3
     rear_port: Rear Port 3
+module-bays:
+  - name: Module Bay 1
+  - name: Module Bay 2
+  - name: Module Bay 3
 device-bays:
 device-bays:
   - name: Device Bay 1
   - name: Device Bay 1
   - name: Device Bay 2
   - name: Device Bay 2
@@ -658,6 +675,7 @@ device-bays:
             'dcim.add_interfacetemplate',
             'dcim.add_interfacetemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_frontporttemplate',
             'dcim.add_rearporttemplate',
             'dcim.add_rearporttemplate',
+            'dcim.add_modulebaytemplate',
             'dcim.add_devicebaytemplate',
             'dcim.add_devicebaytemplate',
         )
         )
 
 
@@ -710,6 +728,10 @@ device-bays:
         self.assertEqual(fp1.rear_port, rp1)
         self.assertEqual(fp1.rear_port, rp1)
         self.assertEqual(fp1.rear_port_position, 1)
         self.assertEqual(fp1.rear_port_position, 1)
 
 
+        self.assertEqual(dt.modulebaytemplates.count(), 3)
+        db1 = ModuleBayTemplate.objects.first()
+        self.assertEqual(db1.name, 'Module Bay 1')
+
         self.assertEqual(dt.devicebaytemplates.count(), 3)
         self.assertEqual(dt.devicebaytemplates.count(), 3)
         db1 = DeviceBayTemplate.objects.first()
         db1 = DeviceBayTemplate.objects.first()
         self.assertEqual(db1.name, 'Device Bay 1')
         self.assertEqual(db1.name, 'Device Bay 1')
@@ -1011,6 +1033,39 @@ class RearPortTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase
         }
         }
 
 
 
 
+class ModuleBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = ModuleBayTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Manufacturer 1', slug='manufacturer-1')
+        devicetypes = (
+            DeviceType(manufacturer=manufacturer, model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturer, model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        ModuleBayTemplate.objects.bulk_create((
+            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 1'),
+            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 2'),
+            ModuleBayTemplate(device_type=devicetypes[0], name='Module Bay Template 3'),
+        ))
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Module Bay Template X',
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Module Bay Template [4-6]',
+        }
+
+        cls.bulk_edit_data = {
+            'description': 'Foo bar',
+        }
+
+
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
 class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
     model = DeviceBayTemplate
     model = DeviceBayTemplate
 
 
@@ -1307,6 +1362,19 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
         url = reverse('dcim:device_frontports', kwargs={'pk': device.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    @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)
+
+        url = reverse('dcim:device_modulebays', kwargs={'pk': device.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_device_devicebays(self):
     def test_device_devicebays(self):
         device = Device.objects.first()
         device = Device.objects.first()
@@ -1807,6 +1875,47 @@ class RearPortTestCase(ViewTestCases.DeviceComponentViewTestCase):
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
 
 
+class ModuleBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
+    model = ModuleBay
+
+    @classmethod
+    def setUpTestData(cls):
+        device = create_test_device('Device 1')
+
+        ModuleBay.objects.bulk_create([
+            ModuleBay(device=device, name='Module Bay 1'),
+            ModuleBay(device=device, name='Module Bay 2'),
+            ModuleBay(device=device, name='Module Bay 3'),
+        ])
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': device.pk,
+            'name': 'Module Bay X',
+            'description': 'A device bay',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_create_data = {
+            'device': device.pk,
+            'name_pattern': 'Module Bay [4-6]',
+            'description': 'A module bay',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'description': 'New description',
+        }
+
+        cls.csv_data = (
+            "device,name",
+            "Device 1,Module Bay 4",
+            "Device 1,Module Bay 5",
+            "Device 1,Module Bay 6",
+        )
+
+
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
 class DeviceBayTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = DeviceBay
     model = DeviceBay
 
 

+ 23 - 0
netbox/dcim/urls.py

@@ -113,6 +113,7 @@ urlpatterns = [
     path('device-types/<int:pk>/interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
     path('device-types/<int:pk>/interfaces/', views.DeviceTypeInterfacesView.as_view(), name='devicetype_interfaces'),
     path('device-types/<int:pk>/front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
     path('device-types/<int:pk>/front-ports/', views.DeviceTypeFrontPortsView.as_view(), name='devicetype_frontports'),
     path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
     path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
+    path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
     path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
     path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
@@ -183,6 +184,14 @@ urlpatterns = [
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
     path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
     path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
 
 
+    # Device bay templates
+    path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'),
+    path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'),
+    path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'),
+    path('module-bay-templates/delete/', views.ModuleBayTemplateBulkDeleteView.as_view(), name='modulebaytemplate_bulk_delete'),
+    path('module-bay-templates/<int:pk>/edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'),
+    path('module-bay-templates/<int:pk>/delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'),
+
     # Device roles
     # Device roles
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
@@ -222,6 +231,7 @@ urlpatterns = [
     path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
     path('devices/<int:pk>/interfaces/', views.DeviceInterfacesView.as_view(), name='device_interfaces'),
     path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
     path('devices/<int:pk>/front-ports/', views.DeviceFrontPortsView.as_view(), name='device_frontports'),
     path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
     path('devices/<int:pk>/rear-ports/', views.DeviceRearPortsView.as_view(), name='device_rearports'),
+    path('devices/<int:pk>/module-bays/', views.DeviceModuleBaysView.as_view(), name='device_modulebays'),
     path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
     path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
@@ -343,6 +353,19 @@ urlpatterns = [
     path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     path('rear-ports/<int:termination_a_id>/connect/<str:termination_b_type>/', views.CableCreateView.as_view(), name='rearport_connect', kwargs={'termination_a_type': RearPort}),
     path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
     path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'),
 
 
+    # Module bays
+    path('module-bays/', views.ModuleBayListView.as_view(), name='modulebay_list'),
+    path('module-bays/add/', views.ModuleBayCreateView.as_view(), name='modulebay_add'),
+    path('module-bays/import/', views.ModuleBayBulkImportView.as_view(), name='modulebay_import'),
+    path('module-bays/edit/', views.ModuleBayBulkEditView.as_view(), name='modulebay_bulk_edit'),
+    path('module-bays/rename/', views.ModuleBayBulkRenameView.as_view(), name='modulebay_bulk_rename'),
+    path('module-bays/delete/', views.ModuleBayBulkDeleteView.as_view(), name='modulebay_bulk_delete'),
+    path('module-bays/<int:pk>/', views.ModuleBayView.as_view(), name='modulebay'),
+    path('module-bays/<int:pk>/edit/', views.ModuleBayEditView.as_view(), name='modulebay_edit'),
+    path('module-bays/<int:pk>/delete/', views.ModuleBayDeleteView.as_view(), name='modulebay_delete'),
+    path('module-bays/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='modulebay_changelog', kwargs={'model': ModuleBay}),
+    path('devices/module-bays/add/', views.DeviceBulkAddModuleBayView.as_view(), name='device_bulk_add_modulebay'),
+
     # Device bays
     # Device bays
     path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
     path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'),
     path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),
     path('device-bays/add/', views.DeviceBayCreateView.as_view(), name='devicebay_add'),

+ 118 - 3
netbox/dcim/views.py

@@ -30,9 +30,9 @@ from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import (
 from .models import (
     Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, PathEndpoint, Platform, PowerFeed, PowerOutlet, PowerOutletTemplate, PowerPanel,
-    PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort, RearPortTemplate, Region, Site,
-    SiteGroup, VirtualChassis,
+    InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, PathEndpoint, Platform, PowerFeed, PowerOutlet,
+    PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort,
+    RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
 )
 )
 
 
 
 
@@ -836,6 +836,12 @@ class DeviceTypeRearPortsView(DeviceTypeComponentsView):
     filterset = filtersets.RearPortTemplateFilterSet
     filterset = filtersets.RearPortTemplateFilterSet
 
 
 
 
+class DeviceTypeModuleBaysView(DeviceTypeComponentsView):
+    child_model = ModuleBayTemplate
+    table = tables.ModuleBayTemplateTable
+    filterset = filtersets.ModuleBayTemplateFilterSet
+
+
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
 class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     child_model = DeviceBayTemplate
     child_model = DeviceBayTemplate
     table = tables.DeviceBayTemplateTable
     table = tables.DeviceBayTemplateTable
@@ -861,6 +867,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         'dcim.add_interfacetemplate',
         'dcim.add_interfacetemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_frontporttemplate',
         'dcim.add_rearporttemplate',
         'dcim.add_rearporttemplate',
+        'dcim.add_modulebaytemplate',
         'dcim.add_devicebaytemplate',
         'dcim.add_devicebaytemplate',
     ]
     ]
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
@@ -873,6 +880,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         ('interfaces', forms.InterfaceTemplateImportForm),
         ('interfaces', forms.InterfaceTemplateImportForm),
         ('rear-ports', forms.RearPortTemplateImportForm),
         ('rear-ports', forms.RearPortTemplateImportForm),
         ('front-ports', forms.FrontPortTemplateImportForm),
         ('front-ports', forms.FrontPortTemplateImportForm),
+        ('module-bays', forms.ModuleBayTemplateImportForm),
         ('device-bays', forms.DeviceBayTemplateImportForm),
         ('device-bays', forms.DeviceBayTemplateImportForm),
     ))
     ))
 
 
@@ -1132,6 +1140,40 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.RearPortTemplateTable
     table = tables.RearPortTemplateTable
 
 
 
 
+#
+# Module bay templates
+#
+
+class ModuleBayTemplateCreateView(generic.ComponentCreateView):
+    queryset = ModuleBayTemplate.objects.all()
+    form = forms.ModuleBayTemplateCreateForm
+    model_form = forms.ModuleBayTemplateForm
+
+
+class ModuleBayTemplateEditView(generic.ObjectEditView):
+    queryset = ModuleBayTemplate.objects.all()
+    model_form = forms.ModuleBayTemplateForm
+
+
+class ModuleBayTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = ModuleBayTemplate.objects.all()
+
+
+class ModuleBayTemplateBulkEditView(generic.BulkEditView):
+    queryset = ModuleBayTemplate.objects.all()
+    table = tables.ModuleBayTemplateTable
+    form = forms.ModuleBayTemplateBulkEditForm
+
+
+class ModuleBayTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = ModuleBayTemplate.objects.all()
+
+
+class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = ModuleBayTemplate.objects.all()
+    table = tables.ModuleBayTemplateTable
+
+
 #
 #
 # Device bay templates
 # Device bay templates
 #
 #
@@ -1388,6 +1430,13 @@ class DeviceRearPortsView(DeviceComponentsView):
     template_name = 'dcim/device/rearports.html'
     template_name = 'dcim/device/rearports.html'
 
 
 
 
+class DeviceModuleBaysView(DeviceComponentsView):
+    child_model = ModuleBay
+    table = tables.DeviceModuleBayTable
+    filterset = filtersets.ModuleBayFilterSet
+    template_name = 'dcim/device/modulebays.html'
+
+
 class DeviceDeviceBaysView(DeviceComponentsView):
 class DeviceDeviceBaysView(DeviceComponentsView):
     child_model = DeviceBay
     child_model = DeviceBay
     table = tables.DeviceDeviceBayTable
     table = tables.DeviceDeviceBayTable
@@ -1978,6 +2027,61 @@ class RearPortBulkDeleteView(generic.BulkDeleteView):
     table = tables.RearPortTable
     table = tables.RearPortTable
 
 
 
 
+#
+# Module bays
+#
+
+class ModuleBayListView(generic.ObjectListView):
+    queryset = ModuleBay.objects.all()
+    filterset = filtersets.ModuleBayFilterSet
+    filterset_form = forms.ModuleBayFilterForm
+    table = tables.ModuleBayTable
+    action_buttons = ('import', 'export')
+
+
+class ModuleBayView(generic.ObjectView):
+    queryset = ModuleBay.objects.all()
+
+
+class ModuleBayCreateView(generic.ComponentCreateView):
+    queryset = ModuleBay.objects.all()
+    form = forms.ModuleBayCreateForm
+    model_form = forms.ModuleBayForm
+
+
+class ModuleBayEditView(generic.ObjectEditView):
+    queryset = ModuleBay.objects.all()
+    model_form = forms.ModuleBayForm
+    template_name = 'dcim/device_component_edit.html'
+
+
+class ModuleBayDeleteView(generic.ObjectDeleteView):
+    queryset = ModuleBay.objects.all()
+
+
+class ModuleBayBulkImportView(generic.BulkImportView):
+    queryset = ModuleBay.objects.all()
+    model_form = forms.ModuleBayCSVForm
+    table = tables.ModuleBayTable
+
+
+class ModuleBayBulkEditView(generic.BulkEditView):
+    queryset = ModuleBay.objects.all()
+    filterset = filtersets.ModuleBayFilterSet
+    table = tables.ModuleBayTable
+    form = forms.ModuleBayBulkEditForm
+
+
+class ModuleBayBulkRenameView(generic.BulkRenameView):
+    queryset = ModuleBay.objects.all()
+
+
+class ModuleBayBulkDeleteView(generic.BulkDeleteView):
+    queryset = ModuleBay.objects.all()
+    filterset = filtersets.ModuleBayFilterSet
+    table = tables.ModuleBayTable
+
+
 #
 #
 # Device bays
 # Device bays
 #
 #
@@ -2234,6 +2338,17 @@ class DeviceBulkAddRearPortView(generic.BulkComponentCreateView):
     default_return_url = 'dcim:device_list'
     default_return_url = 'dcim:device_list'
 
 
 
 
+class DeviceBulkAddModuleBayView(generic.BulkComponentCreateView):
+    parent_model = Device
+    parent_field = 'device'
+    form = forms.ModuleBayBulkCreateForm
+    queryset = ModuleBay.objects.all()
+    model_form = forms.ModuleBayForm
+    filterset = filtersets.DeviceFilterSet
+    table = tables.DeviceTable
+    default_return_url = 'dcim:device_list'
+
+
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
 class DeviceBulkAddDeviceBayView(generic.BulkComponentCreateView):
     parent_model = Device
     parent_model = Device
     parent_field = 'device'
     parent_field = 'device'

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -161,6 +161,7 @@ DEVICES_MENU = Menu(
                 get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']),
                 get_model_item('dcim', 'consoleserverport', 'Console Server Ports', actions=['import']),
                 get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']),
                 get_model_item('dcim', 'powerport', 'Power Ports', actions=['import']),
                 get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']),
                 get_model_item('dcim', 'poweroutlet', 'Power Outlets', actions=['import']),
+                get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']),
                 get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']),
                 get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']),
                 get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
                 get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
             ),
             ),

+ 15 - 0
netbox/templates/dcim/device/base.html

@@ -69,6 +69,13 @@
                         </a>
                         </a>
                     </li>
                     </li>
                 {% endif %}
                 {% endif %}
+                {% if perms.dcim.add_devicebay %}
+                    <li>
+                        <a class="dropdown-item" href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}">
+                            Module Bays
+                        </a>
+                    </li>
+                {% endif %}
                 {% if perms.dcim.add_devicebay %}
                 {% if perms.dcim.add_devicebay %}
                     <li>
                     <li>
                         <a class="dropdown-item" href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}">
                         <a class="dropdown-item" href="{% url 'dcim:devicebay_add' %}?device={{ object.pk }}&return_url={% url 'dcim:device_devicebays' pk=object.pk %}">
@@ -151,6 +158,14 @@
         {% endif %}
         {% endif %}
     {% endwith %}
     {% endwith %}
 
 
+    {% with modulebay_count=object.modulebays.count %}
+        {% if modulebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'module-bays' %} active{% endif %}" href="{% url 'dcim:device_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
     {% with devicebay_count=object.devicebays.count %}
     {% with devicebay_count=object.devicebays.count %}
         {% if devicebay_count %}
         {% if devicebay_count %}
             <li role="presentation" class="nav-item">
             <li role="presentation" class="nav-item">

+ 43 - 0
netbox/templates/dcim/device/modulebays.html

@@ -0,0 +1,43 @@
+{% extends 'dcim/device/base.html' %}
+{% load render_table from django_tables2 %}
+{% load helpers %}
+{% load static %}
+
+{% block content %}
+  <form method="post">
+    {% csrf_token %}
+    {% include 'inc/table_controls_htmx.html' with table_modal="DeviceModuleBayTable_config" %}
+
+    <div class="card">
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+
+    <div class="noprint bulk-buttons">
+        <div class="bulk-button-group">
+            {% if perms.dcim.change_modulebay %}
+                <button type="submit" name="_rename" formaction="{% url 'dcim:modulebay_bulk_rename' %}?return_url={{ object.get_absolute_url }}%23tab_modulebays" class="btn btn-outline-warning btn-sm">
+                    <i class="mdi mdi-pencil-outline" aria-hidden="true"></i> Rename
+                </button>
+                <button type="submit" name="_edit" formaction="{% url 'dcim:modulebay_bulk_edit' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_modulebays" class="btn btn-warning btn-sm">
+                    <i class="mdi mdi-pencil" aria-hidden="true"></i> Edit
+                </button>
+            {% endif %}
+            {% if perms.dcim.delete_modulebay %}
+                <button type="submit" formaction="{% url 'dcim:modulebay_bulk_delete' %}?return_url={{ object.get_absolute_url }}%23tab_modulebays" class="btn btn-outline-danger btn-sm">
+                    <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete selected
+                </button>
+            {% endif %}
+        </div>
+        {% if perms.dcim.add_modulebay %}
+            <div class="bulk-button-group">
+                <a href="{% url 'dcim:modulebay_add' %}?device={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_modulebays" class="btn btn-primary btn-sm">
+                    <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Module Bays
+                </a>
+            </div>
+        {% endif %}
+    </div>
+  </form>
+  {% table_config_form table %}
+{% endblock %}

+ 7 - 0
netbox/templates/dcim/device_list.html

@@ -56,6 +56,13 @@
             </button>
             </button>
           </li>
           </li>
         {% endif %}
         {% endif %}
+        {% if perms.dcim.add_modulebay %}
+          <li>
+            <button type="submit" formaction="{% url 'dcim:device_bulk_add_modulebay' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
+              Module Bays
+            </button>
+          </li>
+        {% endif %}
         {% if perms.dcim.add_inventoryitem %}
         {% if perms.dcim.add_inventoryitem %}
           <li>
           <li>
             <button type="submit" formaction="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">
             <button type="submit" formaction="{% url 'dcim:device_bulk_add_inventoryitem' %}{% if request.GET %}?{{ request.GET.urlencode }}{% endif %}" class="dropdown-item">

+ 11 - 0
netbox/templates/dcim/devicetype/base.html

@@ -38,6 +38,9 @@
         {% if perms.dcim.add_rearporttemplate %}
         {% if perms.dcim.add_rearporttemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
           <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
         {% endif %}
         {% endif %}
+        {% if perms.dcim.add_modulebaytemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:modulebaytemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_modulebays">Module Bays</a></li>
+        {% endif %}
         {% if perms.dcim.add_devicebaytemplate %}
         {% if perms.dcim.add_devicebaytemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
           <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_devicebays">Device Bays</a></li>
         {% endif %}
         {% endif %}
@@ -109,6 +112,14 @@
         {% endif %}
         {% endif %}
     {% endwith %}
     {% endwith %}
 
 
+    {% with modulebay_count=object.modulebaytemplates.count %}
+        {% if modulebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'module-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
     {% with devicebay_count=object.devicebaytemplates.count %}
     {% with devicebay_count=object.devicebaytemplates.count %}
         {% if devicebay_count %}
         {% if devicebay_count %}
             <li role="presentation" class="nav-item">
             <li role="presentation" class="nav-item">

+ 69 - 0
netbox/templates/dcim/modulebay.html

@@ -0,0 +1,69 @@
+{% extends 'dcim/device_component.html' %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Module Bay</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Device</th>
+              <td>
+                <a href="{{ object.device.get_absolute_url }}">{{ object.device }}</a>
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Name</th>
+              <td>{{ object.name }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Label</th>
+              <td>{{ object.label|placeholder }}</td>
+            </tr>
+            <tr>
+              <th scope="row">Description</th>
+              <td>{{ object.description|placeholder }}</td>
+            </tr>
+          </table>
+        </div>
+      </div>
+    {% include 'inc/panels/custom_fields.html' %}
+    {% include 'inc/panels/tags.html' %}
+    {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Installed Module</h5>
+        <div class="card-body">
+        {% if object.module %}
+          {% with module=object.module %}
+            <table class="table table-hover attr-table">
+              <tr>
+                <th scope="row">Module</th>
+                <td>
+                  <a href="{{ module.get_absolute_url }}">{{ module }}</a>
+                </td>
+              </tr>
+              <tr>
+                <th scope="row">Module Type</th>
+                <td>{{ module.module_type }}</td>
+              </tr>
+            </table>
+          {% endwith %}
+        {% else %}
+          <div class="text-muted">None</div>
+        {% endif %}
+        </div>
+      </div>
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}