jeremystretch 4 лет назад
Родитель
Сommit
7777922bef

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

@@ -22,6 +22,7 @@ __all__ = [
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
+    'NestedModuleSerializer',
     'NestedModuleTypeSerializer',
     'NestedPlatformSerializer',
     'NestedPowerFeedSerializer',
@@ -260,6 +261,18 @@ class NestedDeviceSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
 
 
+class NestedModuleSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
+    device = NestedDeviceSerializer(read_only=True)
+    # TODO: Solve circular dependency
+    # module_bay = NestedModuleBaySerializer(read_only=True)
+    module_type = NestedModuleTypeSerializer(read_only=True)
+
+    class Meta:
+        model = models.Module
+        fields = ['id', 'url', 'display', 'device', 'module_bay', 'module_type']
+
+
 class NestedConsoleServerPortSerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail')
     device = NestedDeviceSerializer(read_only=True)
@@ -325,11 +338,11 @@ class NestedFrontPortSerializer(WritableNestedSerializer):
 
 class NestedModuleBaySerializer(WritableNestedSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:modulebay-detail')
-    # module = NestedModuleSerializer(read_only=True)
+    module = NestedModuleSerializer(read_only=True)
 
     class Meta:
-        model = models.DeviceBay
-        fields = ['id', 'url', 'display', 'name']
+        model = models.ModuleBay
+        fields = ['id', 'url', 'display', 'module', 'name']
 
 
 class NestedDeviceBaySerializer(WritableNestedSerializer):

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

@@ -517,6 +517,20 @@ class DeviceSerializer(PrimaryModelSerializer):
         return data
 
 
+class ModuleSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:module-detail')
+    device = NestedDeviceSerializer()
+    module_bay = NestedModuleBaySerializer()
+    module_type = NestedModuleTypeSerializer()
+
+    class Meta:
+        model = Module
+        fields = [
+            'id', 'url', 'display', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
+            'custom_fields', 'created', 'last_updated',
+        ]
+
+
 class DeviceWithConfigContextSerializer(DeviceSerializer):
     config_context = serializers.SerializerMethodField()
 

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

@@ -32,10 +32,11 @@ router.register('rear-port-templates', views.RearPortTemplateViewSet)
 router.register('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 
-# Devices
+# Device/modules
 router.register('device-roles', views.DeviceRoleViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('devices', views.DeviceViewSet)
+router.register('modules', views.ModuleViewSet)
 
 # Device components
 router.register('console-ports', views.ConsolePortViewSet)

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

@@ -377,7 +377,7 @@ class PlatformViewSet(CustomFieldModelViewSet):
 
 
 #
-# Devices
+# Devices/modules
 #
 
 class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
@@ -526,6 +526,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
         return Response(response)
 
 
+class ModuleViewSet(CustomFieldModelViewSet):
+    queryset = Module.objects.prefetch_related(
+        'device', 'module_bay', 'module_type__manufacturer', 'tags',
+    )
+    serializer_class = serializers.ModuleSerializer
+    filterset_class = filtersets.ModuleFilterSet
+
+
 #
 # Device components
 #

+ 37 - 0
netbox/dcim/filtersets.py

@@ -43,6 +43,7 @@ __all__ = (
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
+    'ModuleFilterSet',
     'ModuleTypeFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
@@ -924,6 +925,42 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
         return queryset.exclude(devicebays__isnull=value)
 
 
+class ModuleFilterSet(PrimaryModelFilterSet):
+    q = django_filters.CharFilter(
+        method='search',
+        label='Search',
+    )
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        field_name='module_type__manufacturer',
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
+    )
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        field_name='module_type__manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
+        to_field_name='slug',
+        label='Manufacturer (slug)',
+    )
+    device_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Device.objects.all(),
+        label='Device (ID)',
+    )
+    tag = TagFilter()
+
+    class Meta:
+        model = Module
+        fields = ['id', 'serial', 'asset_tag']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        return queryset.filter(
+            Q(serial__icontains=value.strip()) |
+            Q(asset_tag__icontains=value.strip()) |
+            Q(comments__icontains=value)
+        ).distinct()
+
+
 class DeviceComponentFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
         method='search',

+ 27 - 0
netbox/dcim/forms/bulk_edit.py

@@ -32,6 +32,7 @@ __all__ = (
     'InventoryItemBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
+    'ModuleBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleTypeBulkEditForm',
@@ -473,6 +474,32 @@ class DeviceBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
         ]
 
 
+class ModuleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=Module.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    module_type = DynamicModelChoiceField(
+        queryset=ModuleType.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    serial = forms.CharField(
+        max_length=50,
+        required=False,
+        label='Serial Number'
+    )
+
+    class Meta:
+        nullable_fields = ['serial']
+
+
 class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
         queryset=Cable.objects.all(),

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

@@ -26,6 +26,7 @@ __all__ = (
     'InventoryItemCSVForm',
     'LocationCSVForm',
     'ManufacturerCSVForm',
+    'ModuleCSVForm',
     'ModuleBayCSVForm',
     'PlatformCSVForm',
     'PowerFeedCSVForm',
@@ -400,6 +401,35 @@ class DeviceCSVForm(BaseDeviceCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
 
 
+class ModuleCSVForm(CustomFieldModelCSVForm):
+    device = CSVModelChoiceField(
+        queryset=Device.objects.all(),
+        to_field_name='name'
+    )
+    module_bay = CSVModelChoiceField(
+        queryset=ModuleBay.objects.all(),
+        to_field_name='name'
+    )
+    module_type = CSVModelChoiceField(
+        queryset=ModuleType.objects.all(),
+        to_field_name='model'
+    )
+
+    class Meta:
+        model = Module
+        fields = (
+            'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments',
+        )
+
+    def __init__(self, data=None, *args, **kwargs):
+        super().__init__(data, *args, **kwargs)
+
+        if data:
+            # Limit module_bay queryset by assigned device
+            params = {f"device__{self.fields['device'].to_field_name}": data.get('device')}
+            self.fields['module_bay'].queryset = self.fields['module_bay'].queryset.filter(**params)
+
+
 class ChildDeviceCSVForm(BaseDeviceCSVForm):
     parent = CSVModelChoiceField(
         queryset=Device.objects.all(),

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

@@ -29,6 +29,8 @@ __all__ = (
     'InventoryItemFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
+    'ModuleFilterForm',
+    'ModuleFilterForm',
     'ModuleBayFilterForm',
     'ModuleTypeFilterForm',
     'PlatformFilterForm',
@@ -645,6 +647,37 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
 
 
+class ModuleFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFieldModelFilterForm):
+    model = Module
+    field_groups = [
+        ['q', 'tag'],
+        ['manufacturer_id', 'module_type_id'],
+        ['serial', 'asset_tag'],
+    ]
+    manufacturer_id = DynamicModelMultipleChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        label=_('Manufacturer'),
+        fetch_trigger='open'
+    )
+    module_type_id = DynamicModelMultipleChoiceField(
+        queryset=ModuleType.objects.all(),
+        required=False,
+        query_params={
+            'manufacturer_id': '$manufacturer_id'
+        },
+        label=_('Type'),
+        fetch_trigger='open'
+    )
+    serial = forms.CharField(
+        required=False
+    )
+    asset_tag = forms.CharField(
+        required=False
+    )
+    tag = TagFilterField(model)
+
+
 class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     model = VirtualChassis
     field_groups = [

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

@@ -39,6 +39,7 @@ __all__ = (
     'InventoryItemForm',
     'LocationForm',
     'ManufacturerForm',
+    'ModuleForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleTypeForm',
@@ -651,6 +652,46 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
             self.fields['position'].widget.choices = [(position, f'U{position}')]
 
 
+class ModuleForm(CustomFieldModelForm):
+    device = DynamicModelChoiceField(
+        queryset=Device.objects.all(),
+        required=False,
+        initial_params={
+            'modulebays': '$module_bay'
+        }
+    )
+    module_bay = DynamicModelChoiceField(
+        queryset=ModuleBay.objects.all(),
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False,
+        initial_params={
+            'device_types': '$device_type'
+        }
+    )
+    module_type = DynamicModelChoiceField(
+        queryset=ModuleType.objects.all(),
+        query_params={
+            'manufacturer_id': '$manufacturer'
+        }
+    )
+    comments = CommentField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = Module
+        fields = [
+            'device', 'module_bay', 'manufacturer', 'module_type', 'serial', 'asset_tag', 'tags', 'comments',
+        ]
+
+
 class CableForm(TenancyForm, CustomFieldModelForm):
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),

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

@@ -56,6 +56,9 @@ class DCIMQuery(graphene.ObjectType):
     manufacturer = ObjectField(ManufacturerType)
     manufacturer_list = ObjectListField(ManufacturerType)
 
+    module = ObjectField(ModuleType)
+    module_list = ObjectListField(ModuleType)
+
     module_bay = ObjectField(ModuleBayType)
     module_bay_list = ObjectListField(ModuleBayType)
 

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

@@ -27,6 +27,7 @@ __all__ = (
     'InventoryItemType',
     'LocationType',
     'ManufacturerType',
+    'ModuleType',
     'ModuleBayType',
     'ModuleBayTemplateType',
     'ModuleTypeType',
@@ -257,6 +258,14 @@ class ManufacturerType(OrganizationalObjectType):
         filterset_class = filtersets.ManufacturerFilterSet
 
 
+class ModuleType(ComponentObjectType):
+
+    class Meta:
+        model = models.Module
+        fields = '__all__'
+        filterset_class = filtersets.ModuleFilterSet
+
+
 class ModuleBayType(ComponentObjectType):
 
     class Meta:

+ 75 - 20
netbox/dcim/migrations/0145_modules.py

@@ -95,36 +95,110 @@ class Migration(migrations.Migration):
                 'unique_together': {('manufacturer', 'model')},
             },
         ),
+        migrations.CreateModel(
+            name='ModuleBay',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
+                ('label', models.CharField(blank=True, max_length=64)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('device', '_name'),
+                'unique_together': {('device', 'name')},
+            },
+        ),
+        migrations.CreateModel(
+            name='Module',
+            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)),
+                ('local_context_data', models.JSONField(blank=True, null=True)),
+                ('serial', models.CharField(blank=True, max_length=50)),
+                ('asset_tag', models.CharField(blank=True, max_length=50, null=True, unique=True)),
+                ('comments', models.TextField(blank=True)),
+                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modules', to='dcim.device')),
+                ('module_bay', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, related_name='installed_module', to='dcim.modulebay')),
+                ('module_type', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='instances', to='dcim.moduletype')),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ('module_bay',),
+            },
+        ),
+        migrations.AddField(
+            model_name='consoleport',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleports', to='dcim.module'),
+        ),
         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='consoleserverport',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverports', to='dcim.module'),
+        ),
         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='frontport',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontports', to='dcim.module'),
+        ),
         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='interface',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfaces', to='dcim.module'),
+        ),
         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='poweroutlet',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlets', to='dcim.module'),
+        ),
         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='powerport',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerports', to='dcim.module'),
+        ),
         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='rearport',
+            name='module',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='rearports', to='dcim.module'),
+        ),
         migrations.AddField(
             model_name='rearporttemplate',
             name='module_type',
@@ -140,7 +214,7 @@ class Migration(migrations.Migration):
         ),
         migrations.AlterUniqueTogether(
             name='frontporttemplate',
-            unique_together={('device_type', 'name'), ('module_type', 'name'), ('rear_port', 'rear_port_position')},
+            unique_together={('device_type', 'name'), ('rear_port', 'rear_port_position'), ('module_type', 'name')},
         ),
         migrations.AlterUniqueTogether(
             name='interfacetemplate',
@@ -175,23 +249,4 @@ class Migration(migrations.Migration):
                 'unique_together': {('device_type', 'name')},
             },
         ),
-        migrations.CreateModel(
-            name='ModuleBay',
-            fields=[
-                ('created', models.DateField(auto_now_add=True, null=True)),
-                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
-                ('id', models.BigAutoField(primary_key=True, serialize=False)),
-                ('name', models.CharField(max_length=64)),
-                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
-                ('label', models.CharField(blank=True, max_length=64)),
-                ('description', models.CharField(blank=True, max_length=200)),
-                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')),
-                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
-            ],
-            options={
-                'ordering': ('device', '_name'),
-                'unique_together': {('device', 'name')},
-            },
-        ),
     ]

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

@@ -27,6 +27,7 @@ __all__ = (
     'InventoryItem',
     'Location',
     'Manufacturer',
+    'Module',
     'ModuleBay',
     'ModuleBayTemplate',
     'ModuleType',

+ 23 - 23
netbox/dcim/models/device_component_templates.py

@@ -142,12 +142,12 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
         )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return ConsolePort(
-            device=device,
             name=self.name,
             label=self.label,
-            type=self.type
+            type=self.type,
+            **kwargs
         )
 
 
@@ -169,12 +169,12 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
         )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return ConsoleServerPort(
-            device=device,
             name=self.name,
             label=self.label,
-            type=self.type
+            type=self.type,
+            **kwargs
         )
 
 
@@ -208,14 +208,14 @@ class PowerPortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
         )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return PowerPort(
-            device=device,
             name=self.name,
             label=self.label,
             type=self.type,
             maximum_draw=self.maximum_draw,
-            allocated_draw=self.allocated_draw
+            allocated_draw=self.allocated_draw,
+            **kwargs
         )
 
     def clean(self):
@@ -273,18 +273,18 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
                     f"Parent power port ({self.power_port}) must belong to the same module type"
                 )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         if self.power_port:
-            power_port = PowerPort.objects.get(device=device, name=self.power_port.name)
+            power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs)
         else:
             power_port = None
         return PowerOutlet(
-            device=device,
             name=self.name,
             label=self.label,
             type=self.type,
             power_port=power_port,
-            feed_leg=self.feed_leg
+            feed_leg=self.feed_leg,
+            **kwargs
         )
 
 
@@ -316,13 +316,13 @@ class InterfaceTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
         )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return Interface(
-            device=device,
             name=self.name,
             label=self.label,
             type=self.type,
-            mgmt_only=self.mgmt_only
+            mgmt_only=self.mgmt_only,
+            **kwargs
         )
 
 
@@ -381,19 +381,19 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         except RearPortTemplate.DoesNotExist:
             pass
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         if self.rear_port:
-            rear_port = RearPort.objects.get(device=device, name=self.rear_port.name)
+            rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs)
         else:
             rear_port = None
         return FrontPort(
-            device=device,
             name=self.name,
             label=self.label,
             type=self.type,
             color=self.color,
             rear_port=rear_port,
-            rear_port_position=self.rear_port_position
+            rear_port_position=self.rear_port_position,
+            **kwargs
         )
 
 
@@ -424,14 +424,14 @@ class RearPortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
         )
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return RearPort(
-            device=device,
             name=self.name,
             label=self.label,
             type=self.type,
             color=self.color,
-            positions=self.positions
+            positions=self.positions,
+            **kwargs
         )
 
 

+ 20 - 7
netbox/dcim/models/device_components.py

@@ -87,6 +87,19 @@ class ComponentModel(PrimaryModel):
         return self.device
 
 
+class ModularComponentModel(ComponentModel):
+    module = models.ForeignKey(
+        to='dcim.Module',
+        on_delete=models.CASCADE,
+        related_name='%(class)ss',
+        blank=True,
+        null=True
+    )
+
+    class Meta:
+        abstract = True
+
+
 class LinkTermination(models.Model):
     """
     An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
@@ -234,7 +247,7 @@ class PathEndpoint(models.Model):
 #
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
+class ConsolePort(ModularComponentModel, LinkTermination, PathEndpoint):
     """
     A physical console port within a Device. ConsolePorts connect to ConsoleServerPorts.
     """
@@ -262,7 +275,7 @@ class ConsolePort(ComponentModel, LinkTermination, PathEndpoint):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
+class ConsoleServerPort(ModularComponentModel, LinkTermination, PathEndpoint):
     """
     A physical port within a Device (typically a designated console server) which provides access to ConsolePorts.
     """
@@ -294,7 +307,7 @@ class ConsoleServerPort(ComponentModel, LinkTermination, PathEndpoint):
 #
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
+class PowerPort(ModularComponentModel, LinkTermination, PathEndpoint):
     """
     A physical power supply (intake) port within a Device. PowerPorts connect to PowerOutlets.
     """
@@ -387,7 +400,7 @@ class PowerPort(ComponentModel, LinkTermination, PathEndpoint):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class PowerOutlet(ComponentModel, LinkTermination, PathEndpoint):
+class PowerOutlet(ModularComponentModel, LinkTermination, PathEndpoint):
     """
     A physical power outlet (output) within a Device which provides power to a PowerPort.
     """
@@ -502,7 +515,7 @@ class BaseInterface(models.Model):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
+class Interface(ModularComponentModel, BaseInterface, LinkTermination, PathEndpoint):
     """
     A network interface within a Device. A physical Interface can connect to exactly one other Interface.
     """
@@ -765,7 +778,7 @@ class Interface(ComponentModel, BaseInterface, LinkTermination, PathEndpoint):
 #
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class FrontPort(ComponentModel, LinkTermination):
+class FrontPort(ModularComponentModel, LinkTermination):
     """
     A pass-through port on the front of a Device.
     """
@@ -819,7 +832,7 @@ class FrontPort(ComponentModel, LinkTermination):
 
 
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
-class RearPort(ComponentModel, LinkTermination):
+class RearPort(ModularComponentModel, LinkTermination):
     """
     A pass-through port on the rear of a Device.
     """

+ 89 - 9
netbox/dcim/models/devices.py

@@ -26,6 +26,7 @@ __all__ = (
     'DeviceRole',
     'DeviceType',
     'Manufacturer',
+    'Module',
     'ModuleType',
     'Platform',
     'VirtualChassis',
@@ -906,31 +907,31 @@ class Device(PrimaryModel, ConfigContextModel):
         # If this is a new Device, instantiate all of the related components per the DeviceType definition
         if is_new:
             ConsolePort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.consoleporttemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.consoleporttemplates.all()]
             )
             ConsoleServerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.consoleserverporttemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.consoleserverporttemplates.all()]
             )
             PowerPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.powerporttemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.powerporttemplates.all()]
             )
             PowerOutlet.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.poweroutlettemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.poweroutlettemplates.all()]
             )
             Interface.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.interfacetemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.interfacetemplates.all()]
             )
             RearPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.rearporttemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.rearporttemplates.all()]
             )
             FrontPort.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.frontporttemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.frontporttemplates.all()]
             )
             ModuleBay.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.modulebaytemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.modulebaytemplates.all()]
             )
             DeviceBay.objects.bulk_create(
-                [x.instantiate(self) for x in self.device_type.devicebaytemplates.all()]
+                [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()]
             )
 
         # Update Site and Rack assignment for any child Devices
@@ -1008,6 +1009,85 @@ class Device(PrimaryModel, ConfigContextModel):
         return DeviceStatusChoices.colors.get(self.status, 'secondary')
 
 
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class Module(PrimaryModel, ConfigContextModel):
+    """
+    A Module represents a field-installable component within a Device which may itself hold multiple device components
+    (for example, a line card within a chassis switch). Modules are instantiated from ModuleTypes.
+    """
+    device = models.ForeignKey(
+        to='dcim.Device',
+        on_delete=models.CASCADE,
+        related_name='modules'
+    )
+    module_bay = models.OneToOneField(
+        to='dcim.ModuleBay',
+        on_delete=models.CASCADE,
+        related_name='installed_module'
+    )
+    module_type = models.ForeignKey(
+        to='dcim.ModuleType',
+        on_delete=models.PROTECT,
+        related_name='instances'
+    )
+    serial = models.CharField(
+        max_length=50,
+        blank=True,
+        verbose_name='Serial number'
+    )
+    asset_tag = models.CharField(
+        max_length=50,
+        blank=True,
+        null=True,
+        unique=True,
+        verbose_name='Asset tag',
+        help_text='A unique tag used to identify this device'
+    )
+    comments = models.TextField(
+        blank=True
+    )
+
+    clone_fields = ('device', 'module_type')
+
+    class Meta:
+        ordering = ('module_bay',)
+
+    def __str__(self):
+        return str(self.module_type)
+
+    def get_absolute_url(self):
+        return reverse('dcim:module', args=[self.pk])
+
+    def save(self, *args, **kwargs):
+        is_new = not bool(self.pk)
+
+        super().save(*args, **kwargs)
+
+        # If this is a new Module, instantiate all its related components per the ModuleType definition
+        if is_new:
+            ConsolePort.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleporttemplates.all()]
+            )
+            ConsoleServerPort.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.consoleserverporttemplates.all()]
+            )
+            PowerPort.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.powerporttemplates.all()]
+            )
+            PowerOutlet.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.poweroutlettemplates.all()]
+            )
+            Interface.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.interfacetemplates.all()]
+            )
+            RearPort.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.rearporttemplates.all()]
+            )
+            FrontPort.objects.bulk_create(
+                [x.instantiate(device=self.device, module=self) for x in self.module_type.frontporttemplates.all()]
+            )
+
+
 #
 # Virtual chassis
 #

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

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

+ 11 - 6
netbox/dcim/tables/devices.py

@@ -725,26 +725,31 @@ class ModuleBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
         }
     )
+    installed_module = tables.Column(
+        linkify=True,
+        verbose_name='Installed module'
+    )
     tags = TagColumn(
         url_name='dcim:modulebay_list'
     )
 
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
-        fields = ('pk', 'id', 'name', 'device', 'label', 'description', 'tags')
-        default_columns = ('pk', 'name', 'device', 'label', 'description')
+        fields = ('pk', 'id', 'name', 'device', 'label', 'installed_module', 'description', 'tags')
+        default_columns = ('pk', 'name', 'device', 'label', 'installed_module', 'description')
 
 
 class DeviceModuleBayTable(ModuleBayTable):
     actions = ButtonsColumn(
-        model=ModuleBay,
-        buttons=('edit', 'delete')
+        model=DeviceBay,
+        buttons=('edit', 'delete'),
+        prepend_template=MODULEBAY_BUTTONS
     )
 
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
-        fields = ('pk', 'id', 'name', 'label', 'description', 'tags', 'actions')
-        default_columns = ('pk', 'name', 'label', 'description', 'actions')
+        fields = ('pk', 'id', 'name', 'label', 'description', 'installed_module', 'tags', 'actions')
+        default_columns = ('pk', 'name', 'label', 'description', 'installed_module', 'actions')
 
 
 class InventoryItemTable(DeviceComponentTable):

+ 61 - 0
netbox/dcim/tables/modules.py

@@ -0,0 +1,61 @@
+import django_tables2 as tables
+
+from dcim.models import Module, ModuleType
+from utilities.tables import BaseTable, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn
+
+__all__ = (
+    'ModuleTable',
+    'ModuleTypeTable',
+)
+
+
+class ModuleTypeTable(BaseTable):
+    pk = ToggleColumn()
+    model = tables.Column(
+        linkify=True,
+        verbose_name='Module 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',
+        )
+
+
+class ModuleTable(BaseTable):
+    pk = ToggleColumn()
+    device = tables.Column(
+        linkify=True
+    )
+    module_bay = tables.Column(
+        linkify=True
+    )
+    module_type = tables.Column(
+        linkify=True
+    )
+    comments = MarkdownColumn()
+    tags = TagColumn(
+        url_name='dcim:module_list'
+    )
+
+    class Meta(BaseTable.Meta):
+        model = Module
+        fields = (
+            'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag', 'comments', 'tags',
+        )
+        default_columns = (
+            'pk', 'id', 'device', 'module_bay', 'module_type', 'serial', 'asset_tag',
+        )

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

@@ -1,34 +0,0 @@
-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',
-        )

+ 14 - 0
netbox/dcim/tables/template_code.py

@@ -321,3 +321,17 @@ DEVICEBAY_BUTTONS = """
     {% endif %}
 {% endif %}
 """
+
+MODULEBAY_BUTTONS = """
+{% if perms.dcim.add_module %}
+    {% if record.installed_module %}
+        <a href="{% url 'dcim:module_delete' pk=record.installed_module.pk %}?return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-danger btn-sm">
+            <i class="mdi mdi-minus-thick" aria-hidden="true" title="Remove module"></i>
+        </a>
+    {% else %}
+        <a href="{% url 'dcim:module_add' %}?device={{ record.device.pk }}&module_bay={{ record.pk }}&return_url={% url 'dcim:device_modulebays' pk=object.pk %}" class="btn btn-success btn-sm">
+            <i class="mdi mdi-plus-thick" aria-hidden="true" title="Install module"></i>
+        </a>
+    {% endif %}
+{% endif %}
+"""

+ 62 - 1
netbox/dcim/tests/test_api.py

@@ -7,7 +7,7 @@ from dcim.choices import *
 from dcim.constants import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN
-from utilities.testing import APITestCase, APIViewTestCases
+from utilities.testing import APITestCase, APIViewTestCases, create_test_device
 from virtualization.models import Cluster, ClusterType
 from wireless.models import WirelessLAN
 
@@ -1105,6 +1105,67 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
 
 
+class ModuleTest(APIViewTestCases.APIViewTestCase):
+    model = Module
+    brief_fields = ['device', 'display', 'id', 'module_bay', 'module_type', 'url']
+    bulk_update_data = {
+        'serial': '1234ABCD',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        device = create_test_device('Test Device 1')
+
+        module_types = (
+            ModuleType(manufacturer=manufacturer, model='Module Type 1'),
+            ModuleType(manufacturer=manufacturer, model='Module Type 2'),
+            ModuleType(manufacturer=manufacturer, model='Module Type 3'),
+        )
+        ModuleType.objects.bulk_create(module_types)
+
+        module_bays = (
+            ModuleBay(device=device, name='Module Bay 1'),
+            ModuleBay(device=device, name='Module Bay 2'),
+            ModuleBay(device=device, name='Module Bay 3'),
+            ModuleBay(device=device, name='Module Bay 4'),
+            ModuleBay(device=device, name='Module Bay 5'),
+            ModuleBay(device=device, name='Module Bay 6'),
+        )
+        ModuleBay.objects.bulk_create(module_bays)
+
+        modules = (
+            Module(device=device, module_bay=module_bays[0], module_type=module_types[0]),
+            Module(device=device, module_bay=module_bays[1], module_type=module_types[1]),
+            Module(device=device, module_bay=module_bays[2], module_type=module_types[2]),
+        )
+        Module.objects.bulk_create(modules)
+
+        cls.create_data = [
+            {
+                'device': device.pk,
+                'module_bay': module_bays[3].pk,
+                'module_type': module_types[0].pk,
+                'serial': 'ABC123',
+                'asset_tag': 'Foo1',
+            },
+            {
+                'device': device.pk,
+                'module_bay': module_bays[4].pk,
+                'module_type': module_types[1].pk,
+                'serial': 'DEF456',
+                'asset_tag': 'Foo2',
+            },
+            {
+                'device': device.pk,
+                'module_bay': module_bays[5].pk,
+                'module_type': module_types[2].pk,
+                'serial': 'GHI789',
+                'asset_tag': 'Foo3',
+            },
+        ]
+
+
 class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']

+ 74 - 1
netbox/dcim/tests/test_filtersets.py

@@ -7,7 +7,7 @@ from dcim.models import *
 from ipam.models import ASN, IPAddress, RIR
 from tenancy.models import Tenant, TenantGroup
 from utilities.choices import ColorChoices
-from utilities.testing import ChangeLoggedFilterSetTests
+from utilities.testing import ChangeLoggedFilterSetTests, create_test_device
 from virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
@@ -1648,6 +1648,79 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
+class ModuleTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = Module.objects.all()
+    filterset = ModuleFilterSet
+
+    @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)
+
+        devices = (
+            create_test_device('Test Device 1'),
+            create_test_device('Test Device 2'),
+            create_test_device('Test Device 3'),
+        )
+
+        module_types = (
+            ModuleType(manufacturer=manufacturers[0], model='Module Type 1'),
+            ModuleType(manufacturer=manufacturers[1], model='Module Type 2'),
+            ModuleType(manufacturer=manufacturers[2], model='Module Type 3'),
+        )
+        ModuleType.objects.bulk_create(module_types)
+
+        module_bays = (
+            ModuleBay(device=devices[0], name='Module Bay 1'),
+            ModuleBay(device=devices[0], name='Module Bay 2'),
+            ModuleBay(device=devices[0], name='Module Bay 3'),
+            ModuleBay(device=devices[1], name='Module Bay 1'),
+            ModuleBay(device=devices[1], name='Module Bay 2'),
+            ModuleBay(device=devices[1], name='Module Bay 3'),
+            ModuleBay(device=devices[2], name='Module Bay 1'),
+            ModuleBay(device=devices[2], name='Module Bay 2'),
+            ModuleBay(device=devices[2], name='Module Bay 3'),
+        )
+        ModuleBay.objects.bulk_create(module_bays)
+
+        modules = (
+            Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0], serial='A', asset_tag='A'),
+            Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1], serial='B', asset_tag='B'),
+            Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2], serial='C', asset_tag='C'),
+            Module(device=devices[1], module_bay=module_bays[3], module_type=module_types[0], serial='D', asset_tag='D'),
+            Module(device=devices[1], module_bay=module_bays[4], module_type=module_types[1], serial='E', asset_tag='E'),
+            Module(device=devices[1], module_bay=module_bays[5], module_type=module_types[2], serial='F', asset_tag='F'),
+            Module(device=devices[2], module_bay=module_bays[6], module_type=module_types[0], serial='G', asset_tag='G'),
+            Module(device=devices[2], module_bay=module_bays[7], module_type=module_types[1], serial='H', asset_tag='H'),
+            Module(device=devices[2], module_bay=module_bays[8], module_type=module_types[2], serial='I', asset_tag='I'),
+        )
+        Module.objects.bulk_create(modules)
+
+    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(), 6)
+        params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+    def test_device(self):
+        device_types = Device.objects.all()[:2]
+        params = {'device_id': [device_types[0].pk, device_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6)
+
+    def test_serial(self):
+        params = {'asset_tag': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_asset_tag(self):
+        params = {'asset_tag': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet

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

@@ -1697,6 +1697,75 @@ class DeviceTestCase(ViewTestCases.PrimaryObjectViewTestCase):
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+class ModuleTestCase(
+    # Module does not support bulk renaming (no name field) or
+    # bulk creation (need to specify module bays)
+    ViewTestCases.GetObjectViewTestCase,
+    ViewTestCases.GetObjectChangelogViewTestCase,
+    ViewTestCases.EditObjectViewTestCase,
+    ViewTestCases.DeleteObjectViewTestCase,
+    ViewTestCases.ListObjectsViewTestCase,
+    ViewTestCases.BulkImportObjectsViewTestCase,
+    ViewTestCases.BulkEditObjectsViewTestCase,
+    ViewTestCases.BulkDeleteObjectsViewTestCase,
+):
+    model = Module
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Generic', slug='generic')
+        devices = (
+            create_test_device('Device 1'),
+            create_test_device('Device 2'),
+        )
+
+        module_types = (
+            ModuleType(manufacturer=manufacturer, model='Module Type 1'),
+            ModuleType(manufacturer=manufacturer, model='Module Type 2'),
+            ModuleType(manufacturer=manufacturer, model='Module Type 3'),
+            ModuleType(manufacturer=manufacturer, model='Module Type 4'),
+        )
+        ModuleType.objects.bulk_create(module_types)
+
+        module_bays = (
+            ModuleBay(device=devices[0], name='Module Bay 1'),
+            ModuleBay(device=devices[0], name='Module Bay 2'),
+            ModuleBay(device=devices[0], name='Module Bay 3'),
+            ModuleBay(device=devices[1], name='Module Bay 1'),
+            ModuleBay(device=devices[1], name='Module Bay 2'),
+            ModuleBay(device=devices[1], name='Module Bay 3'),
+        )
+        ModuleBay.objects.bulk_create(module_bays)
+
+        modules = (
+            Module(device=devices[0], module_bay=module_bays[0], module_type=module_types[0]),
+            Module(device=devices[0], module_bay=module_bays[1], module_type=module_types[1]),
+            Module(device=devices[0], module_bay=module_bays[2], module_type=module_types[2]),
+        )
+        Module.objects.bulk_create(modules)
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'device': devices[1].pk,
+            'module_bay': module_bays[3].pk,
+            'module_type': module_types[0].pk,
+            'serial': 'A',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.bulk_edit_data = {
+            'module_type': module_types[3].pk,
+        }
+
+        cls.csv_data = (
+            "device,module_bay,module_type,serial,asset_tag",
+            "Device 2,Module Bay 1,Module Type 1,A,A",
+            "Device 2,Module Bay 2,Module Type 2,B,B",
+            "Device 2,Module Bay 3,Module Type 3,C,C",
+        )
+
+
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
 

+ 14 - 2
netbox/dcim/urls.py

@@ -254,12 +254,24 @@ urlpatterns = [
     path('devices/<int:pk>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
-    path('devices/<int:pk>/changelog/', views.DeviceChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
-    path('devices/<int:pk>/journal/', views.DeviceJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
+    path('devices/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='device_changelog', kwargs={'model': Device}),
+    path('devices/<int:pk>/journal/', ObjectJournalView.as_view(), name='device_journal', kwargs={'model': Device}),
     path('devices/<int:pk>/status/', views.DeviceStatusView.as_view(), name='device_status'),
     path('devices/<int:pk>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
 
+    # Modules
+    path('modules/', views.ModuleListView.as_view(), name='module_list'),
+    path('modules/add/', views.ModuleEditView.as_view(), name='module_add'),
+    path('modules/import/', views.ModuleBulkImportView.as_view(), name='module_import'),
+    path('modules/edit/', views.ModuleBulkEditView.as_view(), name='module_bulk_edit'),
+    path('modules/delete/', views.ModuleBulkDeleteView.as_view(), name='module_bulk_delete'),
+    path('modules/<int:pk>/', views.ModuleView.as_view(), name='module'),
+    path('modules/<int:pk>/edit/', views.ModuleEditView.as_view(), name='module_edit'),
+    path('modules/<int:pk>/delete/', views.ModuleDeleteView.as_view(), name='module_delete'),
+    path('modules/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='module_changelog', kwargs={'model': Module}),
+    path('modules/<int:pk>/journal/', ObjectJournalView.as_view(), name='module_journal', kwargs={'model': Module}),
+
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),

+ 45 - 10
netbox/dcim/views.py

@@ -13,7 +13,7 @@ from django.utils.safestring import mark_safe
 from django.views.generic import View
 
 from circuits.models import Circuit
-from extras.views import ObjectChangeLogView, ObjectConfigContextView, ObjectJournalView
+from extras.views import ObjectConfigContextView
 from ipam.models import ASN, IPAddress, Prefix, Service, VLAN
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
@@ -30,7 +30,7 @@ from .constants import NONCONNECTABLE_IFACE_TYPES
 from .models import (
     Cable, CablePath, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay,
     DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate,
-    InventoryItem, Manufacturer, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed,
+    InventoryItem, Manufacturer, Module, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed,
     PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation,
     RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
 )
@@ -1629,14 +1629,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
     base_template = 'dcim/device/base.html'
 
 
-class DeviceChangeLogView(ObjectChangeLogView):
-    base_template = 'dcim/device/base.html'
-
-
-class DeviceJournalView(ObjectJournalView):
-    base_template = 'dcim/device/base.html'
-
-
 class DeviceEditView(generic.ObjectEditView):
     queryset = Device.objects.all()
     model_form = forms.DeviceForm
@@ -1685,6 +1677,49 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceTable
 
 
+#
+# Devices
+#
+
+class ModuleListView(generic.ObjectListView):
+    queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+    filterset = filtersets.ModuleFilterSet
+    filterset_form = forms.ModuleFilterForm
+    table = tables.ModuleTable
+
+
+class ModuleView(generic.ObjectView):
+    queryset = Module.objects.all()
+
+
+class ModuleEditView(generic.ObjectEditView):
+    queryset = Module.objects.all()
+    model_form = forms.ModuleForm
+
+
+class ModuleDeleteView(generic.ObjectDeleteView):
+    queryset = Module.objects.all()
+
+
+class ModuleBulkImportView(generic.BulkImportView):
+    queryset = Module.objects.all()
+    model_form = forms.ModuleCSVForm
+    table = tables.ModuleTable
+
+
+class ModuleBulkEditView(generic.BulkEditView):
+    queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+    filterset = filtersets.ModuleFilterSet
+    table = tables.ModuleTable
+    form = forms.ModuleBulkEditForm
+
+
+class ModuleBulkDeleteView(generic.BulkDeleteView):
+    queryset = Module.objects.prefetch_related('device', 'module_type__manufacturer')
+    filterset = filtersets.ModuleFilterSet
+    table = tables.ModuleTable
+
+
 #
 # Console ports
 #

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -139,6 +139,7 @@ DEVICES_MENU = Menu(
             label='Devices',
             items=(
                 get_model_item('dcim', 'device', 'Devices'),
+                get_model_item('dcim', 'module', 'Modules'),
                 get_model_item('dcim', 'devicerole', 'Device Roles'),
                 get_model_item('dcim', 'platform', 'Platforms'),
                 get_model_item('dcim', 'virtualchassis', 'Virtual Chassis'),

+ 16 - 16
netbox/templates/dcim/device/base.html

@@ -102,6 +102,22 @@
         </a>
     </li>
 
+    {% with devicebay_count=object.devicebays.count %}
+        {% if devicebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'device-bays' %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with modulebay_count=object.modulebays.count %}
+        {% if modulebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'module-bays' %} active{% endif %}" href="{% url 'dcim:device_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
     {% with interface_count=object.interfaces_count %}
         {% if interface_count %}
             <li role="presentation" class="nav-item">
@@ -158,22 +174,6 @@
         {% endif %}
     {% endwith %}
 
-    {% with modulebay_count=object.modulebays.count %}
-        {% if modulebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'module-bays' %} active{% endif %}" href="{% url 'dcim:device_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with devicebay_count=object.devicebays.count %}
-        {% if devicebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'device-bays' %} active{% endif %}" href="{% url 'dcim:device_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
     {% with inventoryitem_count=object.inventoryitems.count %}
         {% if inventoryitem_count %}
             <li role="presentation" class="nav-item">

+ 16 - 16
netbox/templates/dcim/devicetype/base.html

@@ -56,6 +56,22 @@
         </a>
     </li>
 
+    {% with devicebay_count=object.devicebaytemplates.count %}
+        {% if devicebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'device-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
+    {% with modulebay_count=object.modulebaytemplates.count %}
+        {% if modulebay_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == 'module-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
+
     {% with interface_count=object.interfacetemplates.count %}
         {% if interface_count %}
             <li role="presentation" class="nav-item">
@@ -111,20 +127,4 @@
             </li>
         {% endif %}
     {% endwith %}
-
-    {% with modulebay_count=object.modulebaytemplates.count %}
-        {% if modulebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'module-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_modulebays' pk=object.pk %}">Module Bays {% badge modulebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
-
-    {% with devicebay_count=object.devicebaytemplates.count %}
-        {% if devicebay_count %}
-            <li role="presentation" class="nav-item">
-                <a class="nav-link {% if active_tab == 'device-bay-templates' %} active{% endif %}" href="{% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays {% badge devicebay_count %}</a>
-            </li>
-        {% endif %}
-    {% endwith %}
 {% endblock %}

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

@@ -0,0 +1,154 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load tz %}
+
+{% block breadcrumbs %}
+  {{ block.super }}
+  <li class="breadcrumb-item">
+    <a href="{% url 'dcim:module_list' %}?module_type_id={{ object.module_type.pk }}">{{ object.module_type }}</a>
+  </li>
+{% endblock %}
+
+{% block content %}
+<div class="row">
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">Module</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Device</th>
+            <td>
+              <a href="{{ object.device.get_absolute_url }}">{{ object.device }}</a>
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Device Type</th>
+            <td>
+              <a href="{{ object.device.device_type.get_absolute_url }}">{{ object.device.device_type }}</a>
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Module Type</th>
+            <td>
+              <a href="{{ object.module_type.get_absolute_url }}">{{ object.module_type }}</a>
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Serial Number</th>
+            <td class="font-monospace">{{ object.serial|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Asset Tag</th>
+            <td class="font-monospace">{{ object.asset_tag|placeholder }}</td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% include 'inc/panels/custom_fields.html' %}
+    {% include 'inc/panels/tags.html' %}
+    {% include 'inc/panels/comments.html' %}
+    {% plugin_left_page object %}
+    </div>
+    <div class="col col-md-6">
+      <div class="card">
+        <h5 class="card-header">Components</h5>
+        <div class="card-body">
+          <table class="table table-hover attr-table">
+            <tr>
+              <th scope="row">Interfaces</th>
+              <td>
+                {% with component_count=object.interfaces.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:interface_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Console Ports</th>
+              <td>
+                {% with component_count=object.consoleports.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:consoleport_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Console Server Ports</th>
+              <td>
+                {% with component_count=object.consoleserverports.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:consoleserverport_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Power Ports</th>
+              <td>
+                {% with component_count=object.powerports.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:powerport_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Power Outlets</th>
+              <td>
+                {% with component_count=object.poweroutlets.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:poweroutlet_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Front Ports</th>
+              <td>
+                {% with component_count=object.frontports.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:frontport_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+            <tr>
+              <th scope="row">Rear Ports</th>
+              <td>
+                {% with component_count=object.rearports.count %}
+                  {% if component_count %}
+                    <a href="{% url 'dcim:rearport_list' %}?module={{ object.pk }}">{{ component_count }}</a>
+                  {% else %}
+                    None
+                  {% endif %}
+                {% endwith %}
+              </td>
+            </tr>
+          </table>
+        </div>
+      </div>
+      {% plugin_right_page object %}
+	</div>
+</div>
+<div class="row">
+  <div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}