Răsfoiți Sursa

Add Module model

jeremystretch 4 ani în urmă
părinte
comite
7c60e3c0ff

+ 16 - 1
netbox/dcim/api/nested_serializers.py

@@ -22,6 +22,7 @@ __all__ = [
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
     'NestedModuleBayTemplateSerializer',
+    'NestedModuleTypeSerializer',
     'NestedPlatformSerializer',
     'NestedPlatformSerializer',
     'NestedPowerFeedSerializer',
     'NestedPowerFeedSerializer',
     'NestedPowerOutletSerializer',
     'NestedPowerOutletSerializer',
@@ -119,7 +120,7 @@ class NestedRackReservationSerializer(WritableNestedSerializer):
 
 
 
 
 #
 #
-# Device types
+# Device/module types
 #
 #
 
 
 class NestedManufacturerSerializer(WritableNestedSerializer):
 class NestedManufacturerSerializer(WritableNestedSerializer):
@@ -141,6 +142,20 @@ class NestedDeviceTypeSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
         fields = ['id', 'url', 'display', 'manufacturer', 'model', 'slug', 'device_count']
 
 
 
 
+class NestedModuleTypeSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
+    manufacturer = NestedManufacturerSerializer(read_only=True)
+    # module_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = models.ModuleType
+        fields = ['id', 'url', 'display', 'manufacturer', 'model']
+
+
+#
+# Component templates
+#
+
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
 class NestedConsolePortTemplateSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
 
 

+ 18 - 1
netbox/dcim/api/serializers.py

@@ -261,7 +261,7 @@ class RackElevationDetailFilterSerializer(serializers.Serializer):
 
 
 
 
 #
 #
-# Device types
+# Device/module types
 #
 #
 
 
 class ManufacturerSerializer(PrimaryModelSerializer):
 class ManufacturerSerializer(PrimaryModelSerializer):
@@ -294,6 +294,23 @@ class DeviceTypeSerializer(PrimaryModelSerializer):
         ]
         ]
 
 
 
 
+class ModuleTypeSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:moduletype-detail')
+    manufacturer = NestedManufacturerSerializer()
+    # module_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = ModuleType
+        fields = [
+            'id', 'url', 'display', 'manufacturer', 'model', 'part_number', 'comments', 'tags', 'custom_fields',
+            'created', 'last_updated',
+        ]
+
+
+#
+# Component templates
+#
+
 class ConsolePortTemplateSerializer(ValidatedModelSerializer):
 class ConsolePortTemplateSerializer(ValidatedModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleporttemplate-detail')
     device_type = NestedDeviceTypeSerializer()
     device_type = NestedDeviceTypeSerializer()

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

@@ -16,9 +16,10 @@ router.register('rack-roles', views.RackRoleViewSet)
 router.register('racks', views.RackViewSet)
 router.register('racks', views.RackViewSet)
 router.register('rack-reservations', views.RackReservationViewSet)
 router.register('rack-reservations', views.RackReservationViewSet)
 
 
-# Device types
+# Device/module types
 router.register('manufacturers', views.ManufacturerViewSet)
 router.register('manufacturers', views.ManufacturerViewSet)
 router.register('device-types', views.DeviceTypeViewSet)
 router.register('device-types', views.DeviceTypeViewSet)
+router.register('module-types', views.ModuleTypeViewSet)
 
 
 # Device type components
 # Device type components
 router.register('console-port-templates', views.ConsolePortTemplateViewSet)
 router.register('console-port-templates', views.ConsolePortTemplateViewSet)

+ 10 - 1
netbox/dcim/api/views.py

@@ -271,7 +271,7 @@ class ManufacturerViewSet(CustomFieldModelViewSet):
 
 
 
 
 #
 #
-# Device types
+# Device/module types
 #
 #
 
 
 class DeviceTypeViewSet(CustomFieldModelViewSet):
 class DeviceTypeViewSet(CustomFieldModelViewSet):
@@ -283,6 +283,15 @@ class DeviceTypeViewSet(CustomFieldModelViewSet):
     brief_prefetch_fields = ['manufacturer']
     brief_prefetch_fields = ['manufacturer']
 
 
 
 
+class ModuleTypeViewSet(CustomFieldModelViewSet):
+    queryset = ModuleType.objects.prefetch_related('manufacturer', 'tags').annotate(
+        # module_count=count_related(Module, 'module_type')
+    )
+    serializer_class = serializers.ModuleTypeSerializer
+    filterset_class = filtersets.ModuleTypeFilterSet
+    brief_prefetch_fields = ['manufacturer']
+
+
 #
 #
 # Device type components
 # Device type components
 #
 #

+ 93 - 7
netbox/dcim/filtersets.py

@@ -43,6 +43,7 @@ __all__ = (
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
+    'ModuleTypeFilterSet',
     'PathEndpointFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PlatformFilterSet',
     'PowerConnectionFilterSet',
     'PowerConnectionFilterSet',
@@ -503,6 +504,83 @@ class DeviceTypeFilterSet(PrimaryModelFilterSet):
         return queryset.exclude(devicebaytemplates__isnull=value)
         return queryset.exclude(devicebaytemplates__isnull=value)
 
 
 
 
+class ModuleTypeFilterSet(PrimaryModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
+    )
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        field_name='manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
+        to_field_name='slug',
+        label='Manufacturer (slug)',
+    )
+    console_ports = django_filters.BooleanFilter(
+        method='_console_ports',
+        label='Has console ports',
+    )
+    console_server_ports = django_filters.BooleanFilter(
+        method='_console_server_ports',
+        label='Has console server ports',
+    )
+    power_ports = django_filters.BooleanFilter(
+        method='_power_ports',
+        label='Has power ports',
+    )
+    power_outlets = django_filters.BooleanFilter(
+        method='_power_outlets',
+        label='Has power outlets',
+    )
+    interfaces = django_filters.BooleanFilter(
+        method='_interfaces',
+        label='Has interfaces',
+    )
+    pass_through_ports = django_filters.BooleanFilter(
+        method='_pass_through_ports',
+        label='Has pass-through ports',
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = ModuleType
+        fields = ['id', 'model', 'part_number']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(manufacturer__name__icontains=value) |
+            Q(model__icontains=value) |
+            Q(part_number__icontains=value) |
+            Q(comments__icontains=value)
+        )
+
+    def _console_ports(self, queryset, name, value):
+        return queryset.exclude(consoleporttemplates__isnull=value)
+
+    def _console_server_ports(self, queryset, name, value):
+        return queryset.exclude(consoleserverporttemplates__isnull=value)
+
+    def _power_ports(self, queryset, name, value):
+        return queryset.exclude(powerporttemplates__isnull=value)
+
+    def _power_outlets(self, queryset, name, value):
+        return queryset.exclude(poweroutlettemplates__isnull=value)
+
+    def _interfaces(self, queryset, name, value):
+        return queryset.exclude(interfacetemplates__isnull=value)
+
+    def _pass_through_ports(self, queryset, name, value):
+        return queryset.exclude(
+            frontporttemplates__isnull=value,
+            rearporttemplates__isnull=value
+        )
+
+
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
 class DeviceTypeComponentFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',
@@ -520,28 +598,36 @@ class DeviceTypeComponentFilterSet(django_filters.FilterSet):
         return queryset.filter(name__icontains=value)
         return queryset.filter(name__icontains=value)
 
 
 
 
-class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class ModularDeviceTypeComponentFilterSet(DeviceTypeComponentFilterSet):
+    moduletype_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=ModuleType.objects.all(),
+        field_name='module_type_id',
+        label='Module type (ID)',
+    )
+
+
+class ConsolePortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = ['id', 'name', 'type']
         fields = ['id', 'name', 'type']
 
 
 
 
-class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class ConsoleServerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = ['id', 'name', 'type']
         fields = ['id', 'name', 'type']
 
 
 
 
-class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class PowerPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
 
 
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
         fields = ['id', 'name', 'type', 'maximum_draw', 'allocated_draw']
 
 
 
 
-class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     feed_leg = django_filters.MultipleChoiceFilter(
     feed_leg = django_filters.MultipleChoiceFilter(
         choices=PowerOutletFeedLegChoices,
         choices=PowerOutletFeedLegChoices,
         null_value=None
         null_value=None
@@ -552,7 +638,7 @@ class PowerOutletTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeCompone
         fields = ['id', 'name', 'type', 'feed_leg']
         fields = ['id', 'name', 'type', 'feed_leg']
 
 
 
 
-class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=InterfaceTypeChoices,
         choices=InterfaceTypeChoices,
         null_value=None
         null_value=None
@@ -563,7 +649,7 @@ class InterfaceTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
         fields = ['id', 'name', 'type', 'mgmt_only']
         fields = ['id', 'name', 'type', 'mgmt_only']
 
 
 
 
-class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None
@@ -574,7 +660,7 @@ class FrontPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
         fields = ['id', 'name', 'type', 'color']
         fields = ['id', 'name', 'type', 'color']
 
 
 
 
-class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+class RearPortTemplateFilterSet(ChangeLoggedModelFilterSet, ModularDeviceTypeComponentFilterSet):
     type = django_filters.MultipleChoiceFilter(
     type = django_filters.MultipleChoiceFilter(
         choices=PortTypeChoices,
         choices=PortTypeChoices,
         null_value=None
         null_value=None

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

@@ -34,6 +34,7 @@ __all__ = (
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
+    'ModuleTypeBulkEditForm',
     'PlatformBulkEditForm',
     'PlatformBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerFeedBulkEditForm',
     'PowerOutletBulkEditForm',
     'PowerOutletBulkEditForm',
@@ -327,6 +328,9 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
     )
     )
+    part_number = forms.CharField(
+        required=False
+    )
     u_height = forms.IntegerField(
     u_height = forms.IntegerField(
         min_value=1,
         min_value=1,
         required=False
         required=False
@@ -343,7 +347,24 @@ class DeviceTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     )
     )
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['airflow']
+        nullable_fields = ['part_number', 'airflow']
+
+
+class ModuleTypeBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=ModuleType.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    part_number = forms.CharField(
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['part_number']
 
 
 
 
 class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
 class DeviceRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):

+ 66 - 1
netbox/dcim/forms/filtersets.py

@@ -30,6 +30,7 @@ __all__ = (
     'LocationFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayFilterForm',
+    'ModuleTypeFilterForm',
     'PlatformFilterForm',
     'PlatformFilterForm',
     'PowerConnectionFilterForm',
     'PowerConnectionFilterForm',
     'PowerFeedFilterForm',
     'PowerFeedFilterForm',
@@ -337,7 +338,7 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
     model = DeviceType
     model = DeviceType
     field_groups = [
     field_groups = [
         ['q', 'tag'],
         ['q', 'tag'],
-        ['manufacturer_id', 'subdevice_role', 'airflow'],
+        ['manufacturer_id', 'part_number', 'subdevice_role', 'airflow'],
         ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
         ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
     ]
     ]
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
@@ -346,6 +347,9 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
         label=_('Manufacturer'),
         label=_('Manufacturer'),
         fetch_trigger='open'
         fetch_trigger='open'
     )
     )
+    part_number = forms.CharField(
+        required=False
+    )
     subdevice_role = forms.MultipleChoiceField(
     subdevice_role = forms.MultipleChoiceField(
         choices=add_blank_choice(SubdeviceRoleChoices),
         choices=add_blank_choice(SubdeviceRoleChoices),
         required=False,
         required=False,
@@ -401,6 +405,67 @@ class DeviceTypeFilterForm(CustomFieldModelFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+class ModuleTypeFilterForm(CustomFieldModelFilterForm):
+    model = ModuleType
+    field_groups = [
+        ['q', 'tag'],
+        ['manufacturer_id', 'part_number'],
+        ['console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports'],
+    ]
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+    part_number = forms.CharField(
+        required=False
+    )
+    console_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    console_server_ports = forms.NullBooleanField(
+        required=False,
+        label='Has console server ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_ports = forms.NullBooleanField(
+        required=False,
+        label='Has power ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    power_outlets = forms.NullBooleanField(
+        required=False,
+        label='Has power outlets',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    interfaces = forms.NullBooleanField(
+        required=False,
+        label='Has interfaces',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    pass_through_ports = forms.NullBooleanField(
+        required=False,
+        label='Has pass-through ports',
+        widget=StaticSelect(
+            choices=BOOLEAN_WITH_BLANK_CHOICES
+        )
+    )
+    tag = TagFilterField(model)
+
+
 class DeviceRoleFilterForm(CustomFieldModelFilterForm):
 class DeviceRoleFilterForm(CustomFieldModelFilterForm):
     model = DeviceRole
     model = DeviceRole
     tag = TagFilterField(model)
     tag = TagFilterField(model)

+ 41 - 13
netbox/dcim/forms/models.py

@@ -41,6 +41,7 @@ __all__ = (
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
+    'ModuleTypeForm',
     'PlatformForm',
     'PlatformForm',
     'PopulateDeviceBayForm',
     'PopulateDeviceBayForm',
     'PowerFeedForm',
     'PowerFeedForm',
@@ -414,6 +415,23 @@ class DeviceTypeForm(CustomFieldModelForm):
         }
         }
 
 
 
 
+class ModuleTypeForm(CustomFieldModelForm):
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all()
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = ModuleType
+        fields = [
+            'manufacturer', 'model', 'part_number', 'comments', 'tags',
+        ]
+
+
 class DeviceRoleForm(CustomFieldModelForm):
 class DeviceRoleForm(CustomFieldModelForm):
     slug = SlugField()
     slug = SlugField()
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
@@ -892,10 +910,11 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsolePortTemplate
         model = ConsolePortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
         }
         }
 
 
 
 
@@ -903,10 +922,11 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = ConsoleServerPortTemplate
         model = ConsoleServerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
         }
         }
 
 
 
 
@@ -914,10 +934,11 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerPortTemplate
         model = PowerPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'maximum_draw', 'allocated_draw', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
         }
         }
 
 
 
 
@@ -925,19 +946,21 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = PowerOutletTemplate
         model = PowerOutletTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit power_port choices to current DeviceType
-        if hasattr(self.instance, 'device_type'):
+        # Limit power_port choices to current DeviceType/ModuleType
+        if self.instance.pk:
             self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
             self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
-                device_type=self.instance.device_type
+                device_type=self.instance.device_type,
+                module_type=self.instance.module_type
             )
             )
 
 
 
 
@@ -945,10 +968,11 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = InterfaceTemplate
         model = InterfaceTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'mgmt_only', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'mgmt_only', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
             'type': StaticSelect(),
         }
         }
 
 
@@ -957,20 +981,23 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'color', 'rear_port', 'rear_port_position',
+            'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
             'rear_port': StaticSelect(),
             'rear_port': StaticSelect(),
         }
         }
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
         super().__init__(*args, **kwargs)
 
 
-        # Limit rear_port choices to current DeviceType
-        if hasattr(self.instance, 'device_type'):
+        # Limit rear_port choices to current DeviceType/ModuleType
+        if self.instance.pk:
             self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
             self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
-                device_type=self.instance.device_type
+                device_type=self.instance.device_type,
+                module_type=self.instance.module_type
             )
             )
 
 
 
 
@@ -978,10 +1005,11 @@ class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
     class Meta:
     class Meta:
         model = RearPortTemplate
         model = RearPortTemplate
         fields = [
         fields = [
-            'device_type', 'name', 'label', 'type', 'color', 'positions', 'description',
+            'device_type', 'module_type', 'name', 'label', 'type', 'color', 'positions', 'description',
         ]
         ]
         widgets = {
         widgets = {
             'device_type': forms.HiddenInput(),
             'device_type': forms.HiddenInput(),
+            'module_type': forms.HiddenInput(),
             'type': StaticSelect(),
             'type': StaticSelect(),
         }
         }
 
 

+ 33 - 22
netbox/dcim/forms/object_create.py

@@ -152,11 +152,20 @@ class ComponentTemplateCreateForm(ComponentForm):
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False,
         required=False,
         initial_params={
         initial_params={
-            'device_types': 'device_type'
+            'device_types': 'device_type',
+            'module_types': 'module_type',
         }
         }
     )
     )
     device_type = DynamicModelChoiceField(
     device_type = DynamicModelChoiceField(
         queryset=DeviceType.objects.all(),
         queryset=DeviceType.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    module_type = DynamicModelChoiceField(
+        queryset=ModuleType.objects.all(),
+        required=False,
         query_params={
         query_params={
             'manufacturer_id': '$manufacturer'
             'manufacturer_id': '$manufacturer'
         }
         }
@@ -171,7 +180,9 @@ class ConsolePortTemplateCreateForm(ComponentTemplateCreateForm):
         choices=add_blank_choice(ConsolePortTypeChoices),
         choices=add_blank_choice(ConsolePortTypeChoices),
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
+    field_order = (
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
+    )
 
 
 
 
 class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
 class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
@@ -179,7 +190,9 @@ class ConsoleServerPortTemplateCreateForm(ComponentTemplateCreateForm):
         choices=add_blank_choice(ConsolePortTypeChoices),
         choices=add_blank_choice(ConsolePortTypeChoices),
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'description')
+    field_order = (
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
+    )
 
 
 
 
 class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
 class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
@@ -198,8 +211,8 @@ class PowerPortTemplateCreateForm(ComponentTemplateCreateForm):
         help_text="Allocated power draw (watts)"
         help_text="Allocated power draw (watts)"
     )
     )
     field_order = (
     field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw',
-        'description',
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw',
+        'allocated_draw', 'description',
     )
     )
 
 
 
 
@@ -208,9 +221,13 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
         choices=add_blank_choice(PowerOutletTypeChoices),
         choices=add_blank_choice(PowerOutletTypeChoices),
         required=False
         required=False
     )
     )
-    power_port = forms.ModelChoiceField(
+    power_port = DynamicModelChoiceField(
         queryset=PowerPortTemplate.objects.all(),
         queryset=PowerPortTemplate.objects.all(),
-        required=False
+        required=False,
+        query_params={
+            'devicetype_id': '$device_type',
+            'moduletype_id': '$module_type',
+        }
     )
     )
     feed_leg = forms.ChoiceField(
     feed_leg = forms.ChoiceField(
         choices=add_blank_choice(PowerOutletFeedLegChoices),
         choices=add_blank_choice(PowerOutletFeedLegChoices),
@@ -218,21 +235,10 @@ class PowerOutletTemplateCreateForm(ComponentTemplateCreateForm):
         widget=StaticSelect()
         widget=StaticSelect()
     )
     )
     field_order = (
     field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
         'description',
         'description',
     )
     )
 
 
-    def __init__(self, *args, **kwargs):
-        super().__init__(*args, **kwargs)
-
-        # Limit power_port choices to current DeviceType
-        device_type = DeviceType.objects.get(
-            pk=self.initial.get('device_type') or self.data.get('device_type')
-        )
-        self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
-            device_type=device_type
-        )
-
 
 
 class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
 class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -243,7 +249,10 @@ class InterfaceTemplateCreateForm(ComponentTemplateCreateForm):
         required=False,
         required=False,
         label='Management only'
         label='Management only'
     )
     )
-    field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only', 'description')
+    field_order = (
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only',
+        'description',
+    )
 
 
 
 
 class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
 class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
@@ -260,7 +269,8 @@ class FrontPortTemplateCreateForm(ComponentTemplateCreateForm):
         help_text='Select one rear port assignment for each front port being created.',
         help_text='Select one rear port assignment for each front port being created.',
     )
     )
     field_order = (
     field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'description',
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set',
+        'description',
     )
     )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -325,7 +335,8 @@ class RearPortTemplateCreateForm(ComponentTemplateCreateForm):
         help_text='The number of front ports which may be mapped to each rear port'
         help_text='The number of front ports which may be mapped to each rear port'
     )
     )
     field_order = (
     field_order = (
-        'manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'description',
+        'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions',
+        'description',
     )
     )
 
 
 
 

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

@@ -12,6 +12,7 @@ __all__ = (
     'FrontPortTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleBayTemplateImportForm',
+    'ModuleTypeImportForm',
     'PowerOutletTemplateImportForm',
     'PowerOutletTemplateImportForm',
     'PowerPortTemplateImportForm',
     'PowerPortTemplateImportForm',
     'RearPortTemplateImportForm',
     'RearPortTemplateImportForm',
@@ -32,6 +33,17 @@ class DeviceTypeImportForm(BootstrapMixin, forms.ModelForm):
         ]
         ]
 
 
 
 
+class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm):
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name'
+    )
+
+    class Meta:
+        model = ModuleType
+        fields = ['manufacturer', 'model', 'part_number', 'comments']
+
+
 #
 #
 # Component template import forms
 # Component template import forms
 #
 #

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

@@ -62,6 +62,9 @@ class DCIMQuery(graphene.ObjectType):
     module_bay_template = ObjectField(ModuleBayTemplateType)
     module_bay_template = ObjectField(ModuleBayTemplateType)
     module_bay_template_list = ObjectListField(ModuleBayTemplateType)
     module_bay_template_list = ObjectListField(ModuleBayTemplateType)
 
 
+    module_type = ObjectField(ModuleTypeType)
+    module_type_list = ObjectListField(ModuleTypeType)
+
     platform = ObjectField(PlatformType)
     platform = ObjectField(PlatformType)
     platform_list = ObjectListField(PlatformType)
     platform_list = ObjectListField(PlatformType)
 
 

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

@@ -29,6 +29,7 @@ __all__ = (
     'ManufacturerType',
     'ManufacturerType',
     'ModuleBayType',
     'ModuleBayType',
     'ModuleBayTemplateType',
     'ModuleBayTemplateType',
+    'ModuleTypeType',
     'PlatformType',
     'PlatformType',
     'PowerFeedType',
     'PowerFeedType',
     'PowerOutletType',
     'PowerOutletType',
@@ -272,6 +273,14 @@ class ModuleBayTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.ModuleBayTemplateFilterSet
         filterset_class = filtersets.ModuleBayTemplateFilterSet
 
 
 
 
+class ModuleTypeType(PrimaryObjectType):
+
+    class Meta:
+        model = models.ModuleType
+        fields = '__all__'
+        filterset_class = filtersets.ModuleTypeFilterSet
+
+
 class PlatformType(OrganizationalObjectType):
 class PlatformType(OrganizationalObjectType):
 
 
     class Meta:
     class Meta:

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

@@ -14,6 +14,150 @@ class Migration(migrations.Migration):
     ]
     ]
 
 
     operations = [
     operations = [
+        migrations.AlterModelOptions(
+            name='consoleporttemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='consoleserverporttemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='frontporttemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='interfacetemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='poweroutlettemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='powerporttemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterModelOptions(
+            name='rearporttemplate',
+            options={'ordering': ('device_type', 'module_type', '_name')},
+        ),
+        migrations.AlterField(
+            model_name='consoleporttemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='consoleserverporttemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='frontporttemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='interfacetemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='poweroutlettemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='powerporttemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.devicetype'),
+        ),
+        migrations.AlterField(
+            model_name='rearporttemplate',
+            name='device_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.devicetype'),
+        ),
+        migrations.CreateModel(
+            name='ModuleType',
+            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)),
+                ('model', models.CharField(max_length=100)),
+                ('part_number', models.CharField(blank=True, max_length=50)),
+                ('comments', models.TextField(blank=True)),
+                ('manufacturer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='module_types', to='dcim.manufacturer')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('manufacturer', 'model'),
+                'unique_together': {('manufacturer', 'model')},
+            },
+        ),
+        migrations.AddField(
+            model_name='consoleporttemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='consoleserverporttemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='frontporttemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='interfacetemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='poweroutlettemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='powerporttemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'),
+        ),
+        migrations.AddField(
+            model_name='rearporttemplate',
+            name='module_type',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearporttemplates', to='dcim.moduletype'),
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleporttemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='consoleserverporttemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='frontporttemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='interfacetemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='poweroutlettemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='powerporttemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
+        migrations.AlterUniqueTogether(
+            name='rearporttemplate',
+            unique_together={('device_type', 'name'), ('module_type', 'name')},
+        ),
         migrations.CreateModel(
         migrations.CreateModel(
             name='ModuleBayTemplate',
             name='ModuleBayTemplate',
             fields=[
             fields=[

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

@@ -29,6 +29,7 @@ __all__ = (
     'Manufacturer',
     'Manufacturer',
     'ModuleBay',
     'ModuleBay',
     'ModuleBayTemplate',
     'ModuleBayTemplate',
+    'ModuleType',
     'Platform',
     'Platform',
     'PowerFeed',
     'PowerFeed',
     'PowerOutlet',
     'PowerOutlet',

+ 99 - 25
netbox/dcim/models/device_component_templates.py

@@ -64,7 +64,7 @@ class ComponentTemplateModel(ChangeLoggedModel):
         """
         """
         raise NotImplementedError()
         raise NotImplementedError()
 
 
-    def to_objectchange(self, action):
+    def to_objectchange(self, action, related_object=None):
         # Annotate the parent DeviceType
         # Annotate the parent DeviceType
         try:
         try:
             device_type = self.device_type
             device_type = self.device_type
@@ -74,8 +74,58 @@ class ComponentTemplateModel(ChangeLoggedModel):
         return super().to_objectchange(action, related_object=device_type)
         return super().to_objectchange(action, related_object=device_type)
 
 
 
 
+class ModularComponentTemplateModel(ComponentTemplateModel):
+    """
+    A ComponentTemplateModel which supports optional assignment to a ModuleType.
+    """
+    device_type = models.ForeignKey(
+        to='dcim.DeviceType',
+        on_delete=models.CASCADE,
+        related_name='%(class)ss',
+        blank=True,
+        null=True
+    )
+    module_type = models.ForeignKey(
+        to='dcim.ModuleType',
+        on_delete=models.CASCADE,
+        related_name='%(class)ss',
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+    def to_objectchange(self, action, related_object=None):
+        # Annotate the parent DeviceType or ModuleType
+        try:
+            if getattr(self, 'device_type'):
+                return super().to_objectchange(action, related_object=self.device_type)
+        except ObjectDoesNotExist:
+            pass
+        try:
+            if getattr(self, 'module_type'):
+                return super().to_objectchange(action, related_object=self.module_type)
+        except ObjectDoesNotExist:
+            pass
+        return super().to_objectchange(action)
+
+    def clean(self):
+        super().clean()
+
+        # A component template must belong to a DeviceType *or* to a ModuleType
+        if self.device_type and self.module_type:
+            raise ValidationError(
+                "A component template cannot be associated with both a device type and a module type."
+            )
+        if not self.device_type and not self.module_type:
+            raise ValidationError(
+                "A component template must be associated with either a device type or a module type."
+            )
+
+
 @extras_features('webhooks')
 @extras_features('webhooks')
-class ConsolePortTemplate(ComponentTemplateModel):
+class ConsolePortTemplate(ModularComponentTemplateModel):
     """
     """
     A template for a ConsolePort to be created for a new Device.
     A template for a ConsolePort to be created for a new Device.
     """
     """
@@ -86,8 +136,11 @@ class ConsolePortTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         return ConsolePort(
         return ConsolePort(
@@ -99,7 +152,7 @@ class ConsolePortTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class ConsoleServerPortTemplate(ComponentTemplateModel):
+class ConsoleServerPortTemplate(ModularComponentTemplateModel):
     """
     """
     A template for a ConsoleServerPort to be created for a new Device.
     A template for a ConsoleServerPort to be created for a new Device.
     """
     """
@@ -110,8 +163,11 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         return ConsoleServerPort(
         return ConsoleServerPort(
@@ -123,7 +179,7 @@ class ConsoleServerPortTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class PowerPortTemplate(ComponentTemplateModel):
+class PowerPortTemplate(ModularComponentTemplateModel):
     """
     """
     A template for a PowerPort to be created for a new Device.
     A template for a PowerPort to be created for a new Device.
     """
     """
@@ -146,8 +202,11 @@ class PowerPortTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         return PowerPort(
         return PowerPort(
@@ -170,7 +229,7 @@ class PowerPortTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class PowerOutletTemplate(ComponentTemplateModel):
+class PowerOutletTemplate(ModularComponentTemplateModel):
     """
     """
     A template for a PowerOutlet to be created for a new Device.
     A template for a PowerOutlet to be created for a new Device.
     """
     """
@@ -194,17 +253,25 @@ class PowerOutletTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def clean(self):
     def clean(self):
         super().clean()
         super().clean()
 
 
         # Validate power port assignment
         # Validate power port assignment
-        if self.power_port and self.power_port.device_type != self.device_type:
-            raise ValidationError(
-                "Parent power port ({}) must belong to the same device type".format(self.power_port)
-            )
+        if self.power_port:
+            if self.device_type and self.power_port.device_type != self.device_type:
+                raise ValidationError(
+                    f"Parent power port ({self.power_port}) must belong to the same device type"
+                )
+            if self.module_type and self.power_port.module_type != self.module_type:
+                raise ValidationError(
+                    f"Parent power port ({self.power_port}) must belong to the same module type"
+                )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         if self.power_port:
         if self.power_port:
@@ -222,7 +289,7 @@ class PowerOutletTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class InterfaceTemplate(ComponentTemplateModel):
+class InterfaceTemplate(ModularComponentTemplateModel):
     """
     """
     A template for a physical data interface on a new Device.
     A template for a physical data interface on a new Device.
     """
     """
@@ -243,8 +310,11 @@ class InterfaceTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         return Interface(
         return Interface(
@@ -257,7 +327,7 @@ class InterfaceTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class FrontPortTemplate(ComponentTemplateModel):
+class FrontPortTemplate(ModularComponentTemplateModel):
     """
     """
     Template for a pass-through port on the front of a new Device.
     Template for a pass-through port on the front of a new Device.
     """
     """
@@ -282,9 +352,10 @@ class FrontPortTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
+        ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
             ('device_type', 'name'),
             ('device_type', 'name'),
+            ('module_type', 'name'),
             ('rear_port', 'rear_port_position'),
             ('rear_port', 'rear_port_position'),
         )
         )
 
 
@@ -327,7 +398,7 @@ class FrontPortTemplate(ComponentTemplateModel):
 
 
 
 
 @extras_features('webhooks')
 @extras_features('webhooks')
-class RearPortTemplate(ComponentTemplateModel):
+class RearPortTemplate(ModularComponentTemplateModel):
     """
     """
     Template for a pass-through port on the rear of a new Device.
     Template for a pass-through port on the rear of a new Device.
     """
     """
@@ -347,8 +418,11 @@ class RearPortTemplate(ComponentTemplateModel):
     )
     )
 
 
     class Meta:
     class Meta:
-        ordering = ('device_type', '_name')
-        unique_together = ('device_type', 'name')
+        ordering = ('device_type', 'module_type', '_name')
+        unique_together = (
+            ('device_type', 'name'),
+            ('module_type', 'name'),
+        )
 
 
     def instantiate(self, device):
     def instantiate(self, device):
         return RearPort(
         return RearPort(

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

@@ -26,6 +26,7 @@ __all__ = (
     'DeviceRole',
     'DeviceRole',
     'DeviceType',
     'DeviceType',
     'Manufacturer',
     'Manufacturer',
+    'ModuleType',
     'Platform',
     'Platform',
     'VirtualChassis',
     'VirtualChassis',
 )
 )
@@ -253,6 +254,15 @@ class DeviceType(PrimaryModel):
                 }
                 }
                 for c in self.rearporttemplates.all()
                 for c in self.rearporttemplates.all()
             ]
             ]
+        if self.modulebaytemplates.exists():
+            data['module-bays'] = [
+                {
+                    'name': c.name,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.modulebaytemplates.all()
+            ]
         if self.devicebaytemplates.exists():
         if self.devicebaytemplates.exists():
             data['device-bays'] = [
             data['device-bays'] = [
                 {
                 {
@@ -342,6 +352,136 @@ class DeviceType(PrimaryModel):
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
         return self.subdevice_role == SubdeviceRoleChoices.ROLE_CHILD
 
 
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class ModuleType(PrimaryModel):
+    """
+    A ModuleType represents a hardware element that can be installed within a device and which houses additional
+    components; for example, a line card within a chassis-based switch such as the Cisco Catalyst 6500. Like a
+    DeviceType, each ModuleType can have console, power, interface, and pass-through port templates assigned to it. It
+    cannot, however house device bays or module bays.
+    """
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='module_types'
+    )
+    model = models.CharField(
+        max_length=100
+    )
+    part_number = models.CharField(
+        max_length=50,
+        blank=True,
+        help_text='Discrete part number (optional)'
+    )
+    comments = models.TextField(
+        blank=True
+    )
+
+    clone_fields = ('manufacturer',)
+
+    class Meta:
+        ordering = ('manufacturer', 'model')
+        unique_together = (
+            ('manufacturer', 'model'),
+        )
+
+    def __str__(self):
+        return self.model
+
+    def get_absolute_url(self):
+        return reverse('dcim:moduletype', args=[self.pk])
+
+    def to_yaml(self):
+        data = OrderedDict((
+            ('manufacturer', self.manufacturer.name),
+            ('model', self.model),
+            ('part_number', self.part_number),
+            ('comments', self.comments),
+        ))
+
+        # Component templates
+        if self.consoleporttemplates.exists():
+            data['console-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.consoleporttemplates.all()
+            ]
+        if self.consoleserverporttemplates.exists():
+            data['console-server-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.consoleserverporttemplates.all()
+            ]
+        if self.powerporttemplates.exists():
+            data['power-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'maximum_draw': c.maximum_draw,
+                    'allocated_draw': c.allocated_draw,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.powerporttemplates.all()
+            ]
+        if self.poweroutlettemplates.exists():
+            data['power-outlets'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'power_port': c.power_port.name if c.power_port else None,
+                    'feed_leg': c.feed_leg,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.poweroutlettemplates.all()
+            ]
+        if self.interfacetemplates.exists():
+            data['interfaces'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'mgmt_only': c.mgmt_only,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.interfacetemplates.all()
+            ]
+        if self.frontporttemplates.exists():
+            data['front-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'rear_port': c.rear_port.name,
+                    'rear_port_position': c.rear_port_position,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.frontporttemplates.all()
+            ]
+        if self.rearporttemplates.exists():
+            data['rear-ports'] = [
+                {
+                    'name': c.name,
+                    'type': c.type,
+                    'positions': c.positions,
+                    'label': c.label,
+                    'description': c.description,
+                }
+                for c in self.rearporttemplates.all()
+            ]
+
+        return yaml.dump(dict(data), sort_keys=False)
+
+
 #
 #
 # Devices
 # Devices
 #
 #

+ 1 - 0
netbox/dcim/tables/__init__.py

@@ -6,6 +6,7 @@ from dcim.models import ConsolePort, Interface, PowerPort
 from .cables import *
 from .cables import *
 from .devices import *
 from .devices import *
 from .devicetypes import *
 from .devicetypes import *
+from .moduletypes import *
 from .power import *
 from .power import *
 from .racks import *
 from .racks import *
 from .sites import *
 from .sites import *

+ 34 - 0
netbox/dcim/tables/moduletypes.py

@@ -0,0 +1,34 @@
+import django_tables2 as tables
+
+from dcim.models import ModuleType
+from utilities.tables import BaseTable, MarkdownColumn, TagColumn, ToggleColumn
+
+__all__ = (
+    'ModuleTypeTable',
+)
+
+
+class ModuleTypeTable(BaseTable):
+    pk = ToggleColumn()
+    model = tables.Column(
+        linkify=True,
+        verbose_name='Device Type'
+    )
+    # instance_count = LinkedCountColumn(
+    #     viewname='dcim:module_list',
+    #     url_params={'module_type_id': 'pk'},
+    #     verbose_name='Instances'
+    # )
+    comments = MarkdownColumn()
+    tags = TagColumn(
+        url_name='dcim:moduletype_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = ModuleType
+        fields = (
+            'pk', 'id', 'model', 'manufacturer', 'part_number', 'comments', 'tags',
+        )
+        default_columns = (
+            'pk', 'model', 'manufacturer', 'part_number',
+        )

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

@@ -470,6 +470,45 @@ class DeviceTypeTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
+class ModuleTypeTest(APIViewTestCases.APIViewTestCase):
+    model = ModuleType
+    brief_fields = ['display', 'id', 'manufacturer', 'model', 'url']
+    bulk_update_data = {
+        'part_number': 'ABC123',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturers = (
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+        )
+        Manufacturer.objects.bulk_create(manufacturers)
+
+        module_types = (
+            ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
+            ModuleType(manufacturer=manufacturers[0], model='Module Type 2'),
+            ModuleType(manufacturer=manufacturers[0], model='Module Type 3'),
+        )
+        ModuleType.objects.bulk_create(module_types)
+
+        cls.create_data = [
+            {
+                'manufacturer': manufacturers[1].pk,
+                'model': 'Module Type 4',
+            },
+            {
+                'manufacturer': manufacturers[1].pk,
+                'model': 'Module Type 5',
+            },
+            {
+                'manufacturer': manufacturers[1].pk,
+                'model': 'Module Type 6',
+            },
+        ]
+
+
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
 class ConsolePortTemplateTest(APIViewTestCases.APIViewTestCase):
     model = ConsolePortTemplate
     model = ConsolePortTemplate
     brief_fields = ['display', 'id', 'name', 'url']
     brief_fields = ['display', 'id', 'name', 'url']

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

@@ -773,6 +773,110 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
+class ModuleTypeTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = ModuleType.objects.all()
+    filterset = ModuleTypeFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturers = (
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+            Manufacturer(name='Manufacturer 3', slug='manufacturer-3'),
+        )
+        Manufacturer.objects.bulk_create(manufacturers)
+
+        module_types = (
+            ModuleType(manufacturer=manufacturers[0], model='Model 1', part_number='Part Number 1'),
+            ModuleType(manufacturer=manufacturers[1], model='Model 2', part_number='Part Number 2'),
+            ModuleType(manufacturer=manufacturers[2], model='Model 3', part_number='Part Number 3'),
+        )
+        ModuleType.objects.bulk_create(module_types)
+
+        # Add component templates for filtering
+        ConsolePortTemplate.objects.bulk_create((
+            ConsolePortTemplate(module_type=module_types[0], name='Console Port 1'),
+            ConsolePortTemplate(module_type=module_types[1], name='Console Port 2'),
+        ))
+        ConsoleServerPortTemplate.objects.bulk_create((
+            ConsoleServerPortTemplate(module_type=module_types[0], name='Console Server Port 1'),
+            ConsoleServerPortTemplate(module_type=module_types[1], name='Console Server Port 2'),
+        ))
+        PowerPortTemplate.objects.bulk_create((
+            PowerPortTemplate(module_type=module_types[0], name='Power Port 1'),
+            PowerPortTemplate(module_type=module_types[1], name='Power Port 2'),
+        ))
+        PowerOutletTemplate.objects.bulk_create((
+            PowerOutletTemplate(module_type=module_types[0], name='Power Outlet 1'),
+            PowerOutletTemplate(module_type=module_types[1], name='Power Outlet 2'),
+        ))
+        InterfaceTemplate.objects.bulk_create((
+            InterfaceTemplate(module_type=module_types[0], name='Interface 1'),
+            InterfaceTemplate(module_type=module_types[1], name='Interface 2'),
+        ))
+        rear_ports = (
+            RearPortTemplate(module_type=module_types[0], name='Rear Port 1', type=PortTypeChoices.TYPE_8P8C),
+            RearPortTemplate(module_type=module_types[1], name='Rear Port 2', type=PortTypeChoices.TYPE_8P8C),
+        )
+        RearPortTemplate.objects.bulk_create(rear_ports)
+        FrontPortTemplate.objects.bulk_create((
+            FrontPortTemplate(module_type=module_types[0], name='Front Port 1', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[0]),
+            FrontPortTemplate(module_type=module_types[1], name='Front Port 2', type=PortTypeChoices.TYPE_8P8C, rear_port=rear_ports[1]),
+        ))
+
+    def test_model(self):
+        params = {'model': ['Model 1', 'Model 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_part_number(self):
+        params = {'part_number': ['Part Number 1', 'Part Number 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_manufacturer(self):
+        manufacturers = Manufacturer.objects.all()[:2]
+        params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_console_ports(self):
+        params = {'console_ports': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'console_ports': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_console_server_ports(self):
+        params = {'console_server_ports': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'console_server_ports': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_power_ports(self):
+        params = {'power_ports': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'power_ports': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_power_outlets(self):
+        params = {'power_outlets': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'power_outlets': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_interfaces(self):
+        params = {'interfaces': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'interfaces': 'false'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
+    def test_pass_through_ports(self):
+        params = {'pass_through_ports': 'true'}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'pass_through_ports': '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()
     filterset = ConsolePortTemplateFilterSet
     filterset = ConsolePortTemplateFilterSet

+ 307 - 13
netbox/dcim/tests/test_views.py

@@ -591,7 +591,7 @@ model: TEST-1000
 slug: test-1000
 slug: test-1000
 u_height: 2
 u_height: 2
 subdevice_role: parent
 subdevice_role: parent
-comments: test comment
+comments: Test comment
 console-ports:
 console-ports:
   - name: Console Port 1
   - name: Console Port 1
     type: de-9
     type: de-9
@@ -686,53 +686,53 @@ device-bays:
         response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True)
         response = self.client.post(reverse('dcim:devicetype_import'), data=form_data, follow=True)
         self.assertHttpStatus(response, 200)
         self.assertHttpStatus(response, 200)
 
 
-        dt = DeviceType.objects.get(model='TEST-1000')
-        self.assertEqual(dt.comments, 'test comment')
+        device_type = DeviceType.objects.get(model='TEST-1000')
+        self.assertEqual(device_type.comments, 'Test comment')
 
 
         # Verify all of the components were created
         # Verify all of the components were created
-        self.assertEqual(dt.consoleporttemplates.count(), 3)
+        self.assertEqual(device_type.consoleporttemplates.count(), 3)
         cp1 = ConsolePortTemplate.objects.first()
         cp1 = ConsolePortTemplate.objects.first()
         self.assertEqual(cp1.name, 'Console Port 1')
         self.assertEqual(cp1.name, 'Console Port 1')
         self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
         self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
 
 
-        self.assertEqual(dt.consoleserverporttemplates.count(), 3)
+        self.assertEqual(device_type.consoleserverporttemplates.count(), 3)
         csp1 = ConsoleServerPortTemplate.objects.first()
         csp1 = ConsoleServerPortTemplate.objects.first()
         self.assertEqual(csp1.name, 'Console Server Port 1')
         self.assertEqual(csp1.name, 'Console Server Port 1')
         self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
         self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
 
 
-        self.assertEqual(dt.powerporttemplates.count(), 3)
+        self.assertEqual(device_type.powerporttemplates.count(), 3)
         pp1 = PowerPortTemplate.objects.first()
         pp1 = PowerPortTemplate.objects.first()
         self.assertEqual(pp1.name, 'Power Port 1')
         self.assertEqual(pp1.name, 'Power Port 1')
         self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
         self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
 
 
-        self.assertEqual(dt.poweroutlettemplates.count(), 3)
+        self.assertEqual(device_type.poweroutlettemplates.count(), 3)
         po1 = PowerOutletTemplate.objects.first()
         po1 = PowerOutletTemplate.objects.first()
         self.assertEqual(po1.name, 'Power Outlet 1')
         self.assertEqual(po1.name, 'Power Outlet 1')
         self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
         self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
         self.assertEqual(po1.power_port, pp1)
         self.assertEqual(po1.power_port, pp1)
         self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
         self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
 
 
-        self.assertEqual(dt.interfacetemplates.count(), 3)
+        self.assertEqual(device_type.interfacetemplates.count(), 3)
         iface1 = InterfaceTemplate.objects.first()
         iface1 = InterfaceTemplate.objects.first()
         self.assertEqual(iface1.name, 'Interface 1')
         self.assertEqual(iface1.name, 'Interface 1')
         self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
         self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
         self.assertTrue(iface1.mgmt_only)
         self.assertTrue(iface1.mgmt_only)
 
 
-        self.assertEqual(dt.rearporttemplates.count(), 3)
+        self.assertEqual(device_type.rearporttemplates.count(), 3)
         rp1 = RearPortTemplate.objects.first()
         rp1 = RearPortTemplate.objects.first()
         self.assertEqual(rp1.name, 'Rear Port 1')
         self.assertEqual(rp1.name, 'Rear Port 1')
 
 
-        self.assertEqual(dt.frontporttemplates.count(), 3)
+        self.assertEqual(device_type.frontporttemplates.count(), 3)
         fp1 = FrontPortTemplate.objects.first()
         fp1 = FrontPortTemplate.objects.first()
         self.assertEqual(fp1.name, 'Front Port 1')
         self.assertEqual(fp1.name, 'Front Port 1')
         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)
+        self.assertEqual(device_type.modulebaytemplates.count(), 3)
         db1 = ModuleBayTemplate.objects.first()
         db1 = ModuleBayTemplate.objects.first()
         self.assertEqual(db1.name, 'Module Bay 1')
         self.assertEqual(db1.name, 'Module Bay 1')
 
 
-        self.assertEqual(dt.devicebaytemplates.count(), 3)
+        self.assertEqual(device_type.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')
 
 
@@ -741,7 +741,7 @@ device-bays:
         self.add_permissions('dcim.view_devicetype')
         self.add_permissions('dcim.view_devicetype')
 
 
         # Test default YAML export
         # Test default YAML export
-        response = self.client.get('{}?export'.format(url))
+        response = self.client.get(f'{url}?export')
         self.assertEqual(response.status_code, 200)
         self.assertEqual(response.status_code, 200)
         data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
         data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
         self.assertEqual(len(data), 3)
         self.assertEqual(len(data), 3)
@@ -754,6 +754,300 @@ device-bays:
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
         self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
 
 
 
 
+# TODO: Change base class to PrimaryObjectViewTestCase
+# Blocked by absence of bulk import view for ModuleTypes
+class ModuleTypeTestCase(
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.GetObjectChangelogViewTestCase,
+    ViewTestCases.CreateObjectViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase
+):
+    model = ModuleType
+
+    @classmethod
+    def setUpTestData(cls):
+
+        manufacturers = (
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2')
+        )
+        Manufacturer.objects.bulk_create(manufacturers)
+
+        ModuleType.objects.bulk_create([
+            ModuleType(model='Module Type 1', manufacturer=manufacturers[0]),
+            ModuleType(model='Module Type 2', manufacturer=manufacturers[0]),
+            ModuleType(model='Module Type 3', manufacturer=manufacturers[0]),
+        ])
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'manufacturer': manufacturers[1].pk,
+            'model': 'Device Type X',
+            'part_number': '123ABC',
+            'comments': 'Some comments',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'manufacturer': manufacturers[1].pk,
+            'part_number': '456DEF',
+        }
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_consoleports(self):
+        moduletype = ModuleType.objects.first()
+        console_ports = (
+            ConsolePortTemplate(module_type=moduletype, name='Console Port 1'),
+            ConsolePortTemplate(module_type=moduletype, name='Console Port 2'),
+            ConsolePortTemplate(module_type=moduletype, name='Console Port 3'),
+        )
+        ConsolePortTemplate.objects.bulk_create(console_ports)
+
+        url = reverse('dcim:moduletype_consoleports', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_consoleserverports(self):
+        moduletype = ModuleType.objects.first()
+        console_server_ports = (
+            ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 1'),
+            ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 2'),
+            ConsoleServerPortTemplate(module_type=moduletype, name='Console Server Port 3'),
+        )
+        ConsoleServerPortTemplate.objects.bulk_create(console_server_ports)
+
+        url = reverse('dcim:moduletype_consoleserverports', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_powerports(self):
+        moduletype = ModuleType.objects.first()
+        power_ports = (
+            PowerPortTemplate(module_type=moduletype, name='Power Port 1'),
+            PowerPortTemplate(module_type=moduletype, name='Power Port 2'),
+            PowerPortTemplate(module_type=moduletype, name='Power Port 3'),
+        )
+        PowerPortTemplate.objects.bulk_create(power_ports)
+
+        url = reverse('dcim:moduletype_powerports', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_poweroutlets(self):
+        moduletype = ModuleType.objects.first()
+        power_outlets = (
+            PowerOutletTemplate(module_type=moduletype, name='Power Outlet 1'),
+            PowerOutletTemplate(module_type=moduletype, name='Power Outlet 2'),
+            PowerOutletTemplate(module_type=moduletype, name='Power Outlet 3'),
+        )
+        PowerOutletTemplate.objects.bulk_create(power_outlets)
+
+        url = reverse('dcim:moduletype_poweroutlets', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_interfaces(self):
+        moduletype = ModuleType.objects.first()
+        interfaces = (
+            InterfaceTemplate(module_type=moduletype, name='Interface 1'),
+            InterfaceTemplate(module_type=moduletype, name='Interface 2'),
+            InterfaceTemplate(module_type=moduletype, name='Interface 3'),
+        )
+        InterfaceTemplate.objects.bulk_create(interfaces)
+
+        url = reverse('dcim:moduletype_interfaces', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_rearports(self):
+        moduletype = ModuleType.objects.first()
+        rear_ports = (
+            RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
+            RearPortTemplate(module_type=moduletype, name='Rear Port 2'),
+            RearPortTemplate(module_type=moduletype, name='Rear Port 3'),
+        )
+        RearPortTemplate.objects.bulk_create(rear_ports)
+
+        url = reverse('dcim:moduletype_rearports', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_moduletype_frontports(self):
+        moduletype = ModuleType.objects.first()
+        rear_ports = (
+            RearPortTemplate(module_type=moduletype, name='Rear Port 1'),
+            RearPortTemplate(module_type=moduletype, name='Rear Port 2'),
+            RearPortTemplate(module_type=moduletype, name='Rear Port 3'),
+        )
+        RearPortTemplate.objects.bulk_create(rear_ports)
+        front_ports = (
+            FrontPortTemplate(module_type=moduletype, name='Front Port 1', rear_port=rear_ports[0], rear_port_position=1),
+            FrontPortTemplate(module_type=moduletype, name='Front Port 2', rear_port=rear_ports[1], rear_port_position=1),
+            FrontPortTemplate(module_type=moduletype, name='Front Port 3', rear_port=rear_ports[2], rear_port_position=1),
+        )
+        FrontPortTemplate.objects.bulk_create(front_ports)
+
+        url = reverse('dcim:moduletype_frontports', kwargs={'pk': moduletype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_import_objects(self):
+        """
+        Custom import test for YAML-based imports (versus CSV)
+        """
+        IMPORT_DATA = """
+manufacturer: Generic
+model: TEST-1000
+comments: Test comment
+console-ports:
+  - name: Console Port 1
+    type: de-9
+  - name: Console Port 2
+    type: de-9
+  - name: Console Port 3
+    type: de-9
+console-server-ports:
+  - name: Console Server Port 1
+    type: rj-45
+  - name: Console Server Port 2
+    type: rj-45
+  - name: Console Server Port 3
+    type: rj-45
+power-ports:
+  - name: Power Port 1
+    type: iec-60320-c14
+  - name: Power Port 2
+    type: iec-60320-c14
+  - name: Power Port 3
+    type: iec-60320-c14
+power-outlets:
+  - name: Power Outlet 1
+    type: iec-60320-c13
+    power_port: Power Port 1
+    feed_leg: A
+  - name: Power Outlet 2
+    type: iec-60320-c13
+    power_port: Power Port 1
+    feed_leg: A
+  - name: Power Outlet 3
+    type: iec-60320-c13
+    power_port: Power Port 1
+    feed_leg: A
+interfaces:
+  - name: Interface 1
+    type: 1000base-t
+    mgmt_only: true
+  - name: Interface 2
+    type: 1000base-t
+  - name: Interface 3
+    type: 1000base-t
+rear-ports:
+  - name: Rear Port 1
+    type: 8p8c
+  - name: Rear Port 2
+    type: 8p8c
+  - name: Rear Port 3
+    type: 8p8c
+front-ports:
+  - name: Front Port 1
+    type: 8p8c
+    rear_port: Rear Port 1
+  - name: Front Port 2
+    type: 8p8c
+    rear_port: Rear Port 2
+  - name: Front Port 3
+    type: 8p8c
+    rear_port: Rear Port 3
+"""
+
+        # Create the manufacturer
+        Manufacturer(name='Generic', slug='generic').save()
+
+        # Add all required permissions to the test user
+        self.add_permissions(
+            'dcim.view_moduletype',
+            'dcim.add_moduletype',
+            'dcim.add_consoleporttemplate',
+            'dcim.add_consoleserverporttemplate',
+            'dcim.add_powerporttemplate',
+            'dcim.add_poweroutlettemplate',
+            'dcim.add_interfacetemplate',
+            'dcim.add_frontporttemplate',
+            'dcim.add_rearporttemplate',
+        )
+
+        form_data = {
+            'data': IMPORT_DATA,
+            'format': 'yaml'
+        }
+        response = self.client.post(reverse('dcim:moduletype_import'), data=form_data, follow=True)
+        self.assertHttpStatus(response, 200)
+
+        module_type = ModuleType.objects.get(model='TEST-1000')
+        self.assertEqual(module_type.comments, 'Test comment')
+
+        # Verify all the components were created
+        self.assertEqual(module_type.consoleporttemplates.count(), 3)
+        cp1 = ConsolePortTemplate.objects.first()
+        self.assertEqual(cp1.name, 'Console Port 1')
+        self.assertEqual(cp1.type, ConsolePortTypeChoices.TYPE_DE9)
+
+        self.assertEqual(module_type.consoleserverporttemplates.count(), 3)
+        csp1 = ConsoleServerPortTemplate.objects.first()
+        self.assertEqual(csp1.name, 'Console Server Port 1')
+        self.assertEqual(csp1.type, ConsolePortTypeChoices.TYPE_RJ45)
+
+        self.assertEqual(module_type.powerporttemplates.count(), 3)
+        pp1 = PowerPortTemplate.objects.first()
+        self.assertEqual(pp1.name, 'Power Port 1')
+        self.assertEqual(pp1.type, PowerPortTypeChoices.TYPE_IEC_C14)
+
+        self.assertEqual(module_type.poweroutlettemplates.count(), 3)
+        po1 = PowerOutletTemplate.objects.first()
+        self.assertEqual(po1.name, 'Power Outlet 1')
+        self.assertEqual(po1.type, PowerOutletTypeChoices.TYPE_IEC_C13)
+        self.assertEqual(po1.power_port, pp1)
+        self.assertEqual(po1.feed_leg, PowerOutletFeedLegChoices.FEED_LEG_A)
+
+        self.assertEqual(module_type.interfacetemplates.count(), 3)
+        iface1 = InterfaceTemplate.objects.first()
+        self.assertEqual(iface1.name, 'Interface 1')
+        self.assertEqual(iface1.type, InterfaceTypeChoices.TYPE_1GE_FIXED)
+        self.assertTrue(iface1.mgmt_only)
+
+        self.assertEqual(module_type.rearporttemplates.count(), 3)
+        rp1 = RearPortTemplate.objects.first()
+        self.assertEqual(rp1.name, 'Rear Port 1')
+
+        self.assertEqual(module_type.frontporttemplates.count(), 3)
+        fp1 = FrontPortTemplate.objects.first()
+        self.assertEqual(fp1.name, 'Front Port 1')
+        self.assertEqual(fp1.rear_port, rp1)
+        self.assertEqual(fp1.rear_port_position, 1)
+
+    def test_export_objects(self):
+        url = reverse('dcim:moduletype_list')
+        self.add_permissions('dcim.view_moduletype')
+
+        # Test default YAML export
+        response = self.client.get(f'{url}?export')
+        self.assertEqual(response.status_code, 200)
+        data = list(yaml.load_all(response.content, Loader=yaml.SafeLoader))
+        self.assertEqual(len(data), 3)
+        self.assertEqual(data[0]['manufacturer'], 'Manufacturer 1')
+        self.assertEqual(data[0]['model'], 'Module Type 1')
+
+        # Test table-based export
+        response = self.client.get(f'{url}?export=table')
+        self.assertHttpStatus(response, 200)
+        self.assertEqual(response.get('Content-Type'), 'text/csv; charset=utf-8')
+
+
 #
 #
 # DeviceType components
 # DeviceType components
 #
 #

+ 19 - 0
netbox/dcim/urls.py

@@ -120,6 +120,25 @@ urlpatterns = [
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
     path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
     path('device-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='devicetype_journal', kwargs={'model': DeviceType}),
 
 
+    # Module types
+    path('module-types/', views.ModuleTypeListView.as_view(), name='moduletype_list'),
+    path('module-types/add/', views.ModuleTypeEditView.as_view(), name='moduletype_add'),
+    path('module-types/import/', views.ModuleTypeImportView.as_view(), name='moduletype_import'),
+    path('module-types/edit/', views.ModuleTypeBulkEditView.as_view(), name='moduletype_bulk_edit'),
+    path('module-types/delete/', views.ModuleTypeBulkDeleteView.as_view(), name='moduletype_bulk_delete'),
+    path('module-types/<int:pk>/', views.ModuleTypeView.as_view(), name='moduletype'),
+    path('module-types/<int:pk>/console-ports/', views.ModuleTypeConsolePortsView.as_view(), name='moduletype_consoleports'),
+    path('module-types/<int:pk>/console-server-ports/', views.ModuleTypeConsoleServerPortsView.as_view(), name='moduletype_consoleserverports'),
+    path('module-types/<int:pk>/power-ports/', views.ModuleTypePowerPortsView.as_view(), name='moduletype_powerports'),
+    path('module-types/<int:pk>/power-outlets/', views.ModuleTypePowerOutletsView.as_view(), name='moduletype_poweroutlets'),
+    path('module-types/<int:pk>/interfaces/', views.ModuleTypeInterfacesView.as_view(), name='moduletype_interfaces'),
+    path('module-types/<int:pk>/front-ports/', views.ModuleTypeFrontPortsView.as_view(), name='moduletype_frontports'),
+    path('module-types/<int:pk>/rear-ports/', views.ModuleTypeRearPortsView.as_view(), name='moduletype_rearports'),
+    path('module-types/<int:pk>/edit/', views.ModuleTypeEditView.as_view(), name='moduletype_edit'),
+    path('module-types/<int:pk>/delete/', views.ModuleTypeDeleteView.as_view(), name='moduletype_delete'),
+    path('module-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='moduletype_changelog', kwargs={'model': ModuleType}),
+    path('module-types/<int:pk>/journal/', ObjectJournalView.as_view(), name='moduletype_journal', kwargs={'model': ModuleType}),
+
     # Console port templates
     # Console port templates
     path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
     path('console-port-templates/add/', views.ConsolePortTemplateCreateView.as_view(), name='consoleporttemplate_add'),
     path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),
     path('console-port-templates/edit/', views.ConsolePortTemplateBulkEditView.as_view(), name='consoleporttemplate_bulk_edit'),

+ 128 - 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, ModuleBay, ModuleBayTemplate, PathEndpoint, Platform, PowerFeed, PowerOutlet,
-    PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation, RackRole, RearPort,
-    RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
+    InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed,
+    PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation,
+    RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
 )
 )
 
 
 
 
@@ -56,6 +56,14 @@ class DeviceTypeComponentsView(DeviceComponentsView):
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
         return self.child_model.objects.restrict(request.user, 'view').filter(device_type=parent)
 
 
 
 
+class ModuleTypeComponentsView(DeviceComponentsView):
+    queryset = ModuleType.objects.all()
+    template_name = 'dcim/moduletype/component_templates.html'
+
+    def get_children(self, request, parent):
+        return self.child_model.objects.restrict(request.user, 'view').filter(module_type=parent)
+
+
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
 class BulkDisconnectView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
     """
     """
     An extendable view for disconnection console/power/interface components in bulk.
     An extendable view for disconnection console/power/interface components in bulk.
@@ -902,6 +910,123 @@ class DeviceTypeBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceTypeTable
     table = tables.DeviceTypeTable
 
 
 
 
+#
+# Module types
+#
+
+class ModuleTypeListView(generic.ObjectListView):
+    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+        # instance_count=count_related(Module, 'module_type')
+    )
+    filterset = filtersets.ModuleTypeFilterSet
+    filterset_form = forms.ModuleTypeFilterForm
+    table = tables.ModuleTypeTable
+
+
+class ModuleTypeView(generic.ObjectView):
+    queryset = ModuleType.objects.prefetch_related('manufacturer')
+
+    def get_extra_context(self, request, instance):
+        # instance_count = Module.objects.restrict(request.user).filter(device_type=instance).count()
+
+        return {
+            # 'instance_count': instance_count,
+            'active_tab': 'moduletype',
+        }
+
+
+class ModuleTypeConsolePortsView(ModuleTypeComponentsView):
+    child_model = ConsolePortTemplate
+    table = tables.ConsolePortTemplateTable
+    filterset = filtersets.ConsolePortTemplateFilterSet
+
+
+class ModuleTypeConsoleServerPortsView(ModuleTypeComponentsView):
+    child_model = ConsoleServerPortTemplate
+    table = tables.ConsoleServerPortTemplateTable
+    filterset = filtersets.ConsoleServerPortTemplateFilterSet
+
+
+class ModuleTypePowerPortsView(ModuleTypeComponentsView):
+    child_model = PowerPortTemplate
+    table = tables.PowerPortTemplateTable
+    filterset = filtersets.PowerPortTemplateFilterSet
+
+
+class ModuleTypePowerOutletsView(ModuleTypeComponentsView):
+    child_model = PowerOutletTemplate
+    table = tables.PowerOutletTemplateTable
+    filterset = filtersets.PowerOutletTemplateFilterSet
+
+
+class ModuleTypeInterfacesView(ModuleTypeComponentsView):
+    child_model = InterfaceTemplate
+    table = tables.InterfaceTemplateTable
+    filterset = filtersets.InterfaceTemplateFilterSet
+
+
+class ModuleTypeFrontPortsView(ModuleTypeComponentsView):
+    child_model = FrontPortTemplate
+    table = tables.FrontPortTemplateTable
+    filterset = filtersets.FrontPortTemplateFilterSet
+
+
+class ModuleTypeRearPortsView(ModuleTypeComponentsView):
+    child_model = RearPortTemplate
+    table = tables.RearPortTemplateTable
+    filterset = filtersets.RearPortTemplateFilterSet
+
+
+class ModuleTypeEditView(generic.ObjectEditView):
+    queryset = ModuleType.objects.all()
+    model_form = forms.ModuleTypeForm
+
+
+class ModuleTypeDeleteView(generic.ObjectDeleteView):
+    queryset = ModuleType.objects.all()
+
+
+class ModuleTypeImportView(generic.ObjectImportView):
+    additional_permissions = [
+        'dcim.add_moduletype',
+        'dcim.add_consoleporttemplate',
+        'dcim.add_consoleserverporttemplate',
+        'dcim.add_powerporttemplate',
+        'dcim.add_poweroutlettemplate',
+        'dcim.add_interfacetemplate',
+        'dcim.add_frontporttemplate',
+        'dcim.add_rearporttemplate',
+    ]
+    queryset = ModuleType.objects.all()
+    model_form = forms.ModuleTypeImportForm
+    related_object_forms = OrderedDict((
+        ('console-ports', forms.ConsolePortTemplateImportForm),
+        ('console-server-ports', forms.ConsoleServerPortTemplateImportForm),
+        ('power-ports', forms.PowerPortTemplateImportForm),
+        ('power-outlets', forms.PowerOutletTemplateImportForm),
+        ('interfaces', forms.InterfaceTemplateImportForm),
+        ('rear-ports', forms.RearPortTemplateImportForm),
+        ('front-ports', forms.FrontPortTemplateImportForm),
+    ))
+
+
+class ModuleTypeBulkEditView(generic.BulkEditView):
+    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+        # instance_count=count_related(Module, 'module_type')
+    )
+    filterset = filtersets.ModuleTypeFilterSet
+    table = tables.ModuleTypeTable
+    form = forms.ModuleTypeBulkEditForm
+
+
+class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
+    queryset = ModuleType.objects.prefetch_related('manufacturer').annotate(
+        # instance_count=count_related(Module, 'module_type')
+    )
+    filterset = filtersets.ModuleTypeFilterSet
+    table = tables.ModuleTypeTable
+
+
 #
 #
 # Console port templates
 # Console port templates
 #
 #

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -148,6 +148,7 @@ DEVICES_MENU = Menu(
             label='Device Types',
             label='Device Types',
             items=(
             items=(
                 get_model_item('dcim', 'devicetype', 'Device Types'),
                 get_model_item('dcim', 'devicetype', 'Device Types'),
+                get_model_item('dcim', 'moduletype', 'Module Types'),
                 get_model_item('dcim', 'manufacturer', 'Manufacturers'),
                 get_model_item('dcim', 'manufacturer', 'Manufacturers'),
             ),
             ),
         ),
         ),

+ 48 - 0
netbox/templates/dcim/moduletype.html

@@ -0,0 +1,48 @@
+{% extends 'dcim/moduletype/base.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load plugins %}
+
+{% block content %}
+  <div class="row">
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Module Type</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <td>Manufacturer</td>
+              <td><a href="{{ object.manufacturer.get_absolute_url }}">{{ object.manufacturer }}</a></td>
+            </tr>
+            <tr>
+              <td>Model Name</td>
+              <td>{{ object.model }}</td>
+            </tr>
+            <tr>
+              <td>Part Number</td>
+              <td>{{ object.part_number|placeholder }}</td>
+            </tr>
+            {% comment %}
+            <tr>
+              <td>Instances</td>
+              <td><a href="{% url 'dcim:module_list' %}?module_type_id={{ object.pk }}">{{ instance_count }}</a></td>
+            </tr>
+            {% endcomment %}
+          </table>
+        </div>
+      </div>
+      {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      {% include 'inc/panels/custom_fields.html' %}
+      {% include 'inc/panels/tags.html' %}
+      {% include 'inc/panels/comments.html' %}
+      {% plugin_right_page object %}
+    </div>
+  </div>
+  <div class="row">
+    <div class="col col-md-12">
+      {% plugin_full_width_page object %}
+    </div>
+  </div>
+{% endblock %}

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

@@ -0,0 +1,108 @@
+{% extends 'generic/object.html' %}
+{% load buttons %}
+{% load helpers %}
+{% load plugins %}
+
+{% block title %}{{ object.manufacturer }} {{ object.model }}{% endblock %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:moduletype_list' %}?manufacturer_id={{ object.manufacturer.pk }}">{{ object.manufacturer }}</a></li>
+{% endblock %}
+
+{% block extra_controls %}
+  {% if perms.dcim.change_devicetype %}
+    <div class="dropdown">
+      <button type="button" class="btn btn-primary btn-sm dropdown-toggle"data-bs-toggle="dropdown" aria-expanded="false">
+        <i class="mdi mdi-plus-thick" aria-hidden="true"></i> Add Components
+      </button>
+      <ul class="dropdown-menu">
+        {% if perms.dcim.add_consoleporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:consoleporttemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_consoleports">Console Ports</a></li>
+        {% endif %}
+        {% if perms.dcim.add_consoleserverporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:consoleserverporttemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_consoleserverports">Console Server Ports</a></li>
+        {% endif %}
+        {% if perms.dcim.add_powerporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:powerporttemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_powerports">Power Ports</a></li>
+        {% endif %}
+        {% if perms.dcim.add_poweroutlettemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:poweroutlettemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_poweroutlets">Power Outlets</a></li>
+        {% endif %}
+        {% if perms.dcim.add_interfacetemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:interfacetemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_interfaces">Interfaces</a></li>
+        {% endif %}
+        {% if perms.dcim.add_frontporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:frontporttemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_frontports">Front Ports</a></li>
+        {% endif %}
+        {% if perms.dcim.add_rearporttemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:rearporttemplate_add' %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_rearports">Rear Ports</a></li>
+        {% endif %}
+      </ul>
+    </div>
+  {% endif %}
+{% endblock %}
+
+{% block tab_items %}
+    <li role="presentation" class="nav-item">
+        <a href="{% url 'dcim:devicetype' pk=object.pk %}" class="nav-link{% if active_tab == 'moduletype' %} active{% endif %}">
+            Module Type
+        </a>
+    </li>
+
+    {% with interface_count=object.interfacetemplates.count %}
+        {% if interface_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'interface-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_interfaces' pk=object.pk %}">Interfaces {% badge interface_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with frontport_count=object.frontporttemplates.count %}
+        {% if frontport_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'front-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_frontports' pk=object.pk %}">Front Ports {% badge frontport_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with rearport_count=object.rearporttemplates.count %}
+        {% if rearport_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'rear-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_rearports' pk=object.pk %}">Rear Ports {% badge rearport_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with consoleport_count=object.consoleporttemplates.count %}
+        {% if consoleport_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'console-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_consoleports' pk=object.pk %}">Console Ports {% badge consoleport_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with consoleserverport_count=object.consoleserverporttemplates.count %}
+        {% if consoleserverport_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'console-server-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_consoleserverports' pk=object.pk %}">Console Server Ports {% badge consoleserverport_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with powerport_count=object.powerporttemplates.count %}
+        {% if powerport_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'power-port-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_powerports' pk=object.pk %}">Power Ports {% badge powerport_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with poweroutlet_count=object.poweroutlettemplates.count %}
+        {% if poweroutlet_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'power-outlet-templates' %} active{% endif %}" href="{% url 'dcim:moduletype_poweroutlets' pk=object.pk %}">Power Outlets {% badge poweroutlet_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+{% endblock %}

+ 44 - 0
netbox/templates/dcim/moduletype/component_templates.html

@@ -0,0 +1,44 @@
+{% extends 'dcim/moduletype/base.html' %}
+{% load render_table from django_tables2 %}
+{% load helpers %}
+
+{% block content %}
+  {% if perms.dcim.change_moduletype %}
+    <form method="post">
+        {% csrf_token %}
+        <div class="card">
+            <h5 class="card-header">{{ title }}</h5>
+            <div class="card-body" id="object_list">
+              {% include 'htmx/table.html' %}
+            </div>
+            <div class="card-footer noprint">
+                {% if table.rows %}
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_rename" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning">
+                        <span class="mdi mdi-pencil-outline" aria-hidden="true"></span> Rename
+                    </button>
+                    <button type="submit" name="_edit" formaction="{% url table.Meta.model|viewname:"bulk_edit" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-warning">
+                        <span class="mdi mdi-pencil" aria-hidden="true"></span> Edit
+                    </button>
+                    <button type="submit" name="_delete" formaction="{% url table.Meta.model|viewname:"bulk_delete" %}?return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-danger">
+                        <i class="mdi mdi-trash-can-outline" aria-hidden="true"></i> Delete
+                    </button>
+                {% endif %}
+                <div class="float-end">
+                    <a href="{% url table.Meta.model|viewname:"add" %}?module_type={{ object.pk }}&return_url={{ object.get_absolute_url }}%23tab_{{ tab }}" class="btn btn-primary btn-sm">
+                        <i class="mdi mdi-plus-thick" aria-hidden="true"></i>
+                        Add {{ title }}
+                    </a>
+                </div>
+                <div class="clearfix"></div>
+            </div>
+        </div>
+    </form>
+  {% else %}
+    <div class="card">
+      <h5 class="card-header">{{ title }}</h5>
+      <div class="card-body" id="object_list">
+        {% include 'htmx/table.html' %}
+      </div>
+    </div>
+  {% endif %}
+{% endblock content %}