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

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

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

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

@@ -517,6 +517,20 @@ class DeviceSerializer(PrimaryModelSerializer):
         return data
         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):
 class DeviceWithConfigContextSerializer(DeviceSerializer):
     config_context = serializers.SerializerMethodField()
     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('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 
 
-# Devices
+# Device/modules
 router.register('device-roles', views.DeviceRoleViewSet)
 router.register('device-roles', views.DeviceRoleViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('platforms', views.PlatformViewSet)
 router.register('devices', views.DeviceViewSet)
 router.register('devices', views.DeviceViewSet)
+router.register('modules', views.ModuleViewSet)
 
 
 # Device components
 # Device components
 router.register('console-ports', views.ConsolePortViewSet)
 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):
 class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
@@ -526,6 +526,14 @@ class DeviceViewSet(ConfigContextQuerySetMixin, CustomFieldModelViewSet):
         return Response(response)
         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
 # Device components
 #
 #

+ 37 - 0
netbox/dcim/filtersets.py

@@ -43,6 +43,7 @@ __all__ = (
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayTemplateFilterSet',
     'ModuleBayTemplateFilterSet',
+    'ModuleFilterSet',
     'ModuleTypeFilterSet',
     'ModuleTypeFilterSet',
     'PathEndpointFilterSet',
     'PathEndpointFilterSet',
     'PlatformFilterSet',
     'PlatformFilterSet',
@@ -924,6 +925,42 @@ class DeviceFilterSet(PrimaryModelFilterSet, TenancyFilterSet, LocalConfigContex
         return queryset.exclude(devicebays__isnull=value)
         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):
 class DeviceComponentFilterSet(django_filters.FilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

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

@@ -32,6 +32,7 @@ __all__ = (
     'InventoryItemBulkEditForm',
     'InventoryItemBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
+    'ModuleBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleBayTemplateBulkEditForm',
     'ModuleTypeBulkEditForm',
     '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):
 class CableBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
     pk = forms.ModelMultipleChoiceField(
     pk = forms.ModelMultipleChoiceField(
         queryset=Cable.objects.all(),
         queryset=Cable.objects.all(),

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

@@ -26,6 +26,7 @@ __all__ = (
     'InventoryItemCSVForm',
     'InventoryItemCSVForm',
     'LocationCSVForm',
     'LocationCSVForm',
     'ManufacturerCSVForm',
     'ManufacturerCSVForm',
+    'ModuleCSVForm',
     'ModuleBayCSVForm',
     'ModuleBayCSVForm',
     'PlatformCSVForm',
     'PlatformCSVForm',
     'PowerFeedCSVForm',
     'PowerFeedCSVForm',
@@ -400,6 +401,35 @@ class DeviceCSVForm(BaseDeviceCSVForm):
             self.fields['rack'].queryset = self.fields['rack'].queryset.filter(**params)
             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):
 class ChildDeviceCSVForm(BaseDeviceCSVForm):
     parent = CSVModelChoiceField(
     parent = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),

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

@@ -29,6 +29,8 @@ __all__ = (
     'InventoryItemFilterForm',
     'InventoryItemFilterForm',
     'LocationFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
+    'ModuleFilterForm',
+    'ModuleFilterForm',
     'ModuleBayFilterForm',
     'ModuleBayFilterForm',
     'ModuleTypeFilterForm',
     'ModuleTypeFilterForm',
     'PlatformFilterForm',
     'PlatformFilterForm',
@@ -645,6 +647,37 @@ class DeviceFilterForm(LocalConfigContextFilterForm, TenancyFilterForm, CustomFi
     tag = TagFilterField(model)
     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):
 class VirtualChassisFilterForm(TenancyFilterForm, CustomFieldModelFilterForm):
     model = VirtualChassis
     model = VirtualChassis
     field_groups = [
     field_groups = [

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

@@ -39,6 +39,7 @@ __all__ = (
     'InventoryItemForm',
     'InventoryItemForm',
     'LocationForm',
     'LocationForm',
     'ManufacturerForm',
     'ManufacturerForm',
+    'ModuleForm',
     'ModuleBayForm',
     'ModuleBayForm',
     'ModuleBayTemplateForm',
     'ModuleBayTemplateForm',
     'ModuleTypeForm',
     'ModuleTypeForm',
@@ -651,6 +652,46 @@ class DeviceForm(TenancyForm, CustomFieldModelForm):
             self.fields['position'].widget.choices = [(position, f'U{position}')]
             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):
 class CableForm(TenancyForm, CustomFieldModelForm):
     tags = DynamicModelMultipleChoiceField(
     tags = DynamicModelMultipleChoiceField(
         queryset=Tag.objects.all(),
         queryset=Tag.objects.all(),

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

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

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

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

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

@@ -95,36 +95,110 @@ class Migration(migrations.Migration):
                 'unique_together': {('manufacturer', 'model')},
                 '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(
         migrations.AddField(
             model_name='consoleporttemplate',
             model_name='consoleporttemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleporttemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='consoleserverporttemplate',
             model_name='consoleserverporttemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='consoleserverporttemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='frontporttemplate',
             model_name='frontporttemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='frontporttemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='interfacetemplate',
             model_name='interfacetemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='interfacetemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='poweroutlettemplate',
             model_name='poweroutlettemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='poweroutlettemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='powerporttemplate',
             model_name='powerporttemplate',
             name='module_type',
             name='module_type',
             field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='powerporttemplates', to='dcim.moduletype'),
             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(
         migrations.AddField(
             model_name='rearporttemplate',
             model_name='rearporttemplate',
             name='module_type',
             name='module_type',
@@ -140,7 +214,7 @@ class Migration(migrations.Migration):
         ),
         ),
         migrations.AlterUniqueTogether(
         migrations.AlterUniqueTogether(
             name='frontporttemplate',
             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(
         migrations.AlterUniqueTogether(
             name='interfacetemplate',
             name='interfacetemplate',
@@ -175,23 +249,4 @@ class Migration(migrations.Migration):
                 'unique_together': {('device_type', 'name')},
                 'unique_together': {('device_type', 'name')},
             },
             },
         ),
         ),
-        migrations.CreateModel(
-            name='ModuleBay',
-            fields=[
-                ('created', models.DateField(auto_now_add=True, null=True)),
-                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
-                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
-                ('id', models.BigAutoField(primary_key=True, serialize=False)),
-                ('name', models.CharField(max_length=64)),
-                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
-                ('label', models.CharField(blank=True, max_length=64)),
-                ('description', models.CharField(blank=True, max_length=200)),
-                ('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='modulebays', to='dcim.device')),
-                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
-            ],
-            options={
-                'ordering': ('device', '_name'),
-                'unique_together': {('device', 'name')},
-            },
-        ),
     ]
     ]

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

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

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

@@ -142,12 +142,12 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
             ('module_type', 'name'),
         )
         )
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return ConsolePort(
         return ConsolePort(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
-            type=self.type
+            type=self.type,
+            **kwargs
         )
         )
 
 
 
 
@@ -169,12 +169,12 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
             ('module_type', 'name'),
         )
         )
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return ConsoleServerPort(
         return ConsoleServerPort(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
-            type=self.type
+            type=self.type,
+            **kwargs
         )
         )
 
 
 
 
@@ -208,14 +208,14 @@ class PowerPortTemplate(ModularComponentTemplateModel):
             ('module_type', 'name'),
             ('module_type', 'name'),
         )
         )
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return PowerPort(
         return PowerPort(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
             maximum_draw=self.maximum_draw,
             maximum_draw=self.maximum_draw,
-            allocated_draw=self.allocated_draw
+            allocated_draw=self.allocated_draw,
+            **kwargs
         )
         )
 
 
     def clean(self):
     def clean(self):
@@ -273,18 +273,18 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
                     f"Parent power port ({self.power_port}) must belong to the same module type"
                     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:
         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:
         else:
             power_port = None
             power_port = None
         return PowerOutlet(
         return PowerOutlet(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
             power_port=power_port,
             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'),
             ('module_type', 'name'),
         )
         )
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return Interface(
         return Interface(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
-            mgmt_only=self.mgmt_only
+            mgmt_only=self.mgmt_only,
+            **kwargs
         )
         )
 
 
 
 
@@ -381,19 +381,19 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         except RearPortTemplate.DoesNotExist:
         except RearPortTemplate.DoesNotExist:
             pass
             pass
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         if self.rear_port:
         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:
         else:
             rear_port = None
             rear_port = None
         return FrontPort(
         return FrontPort(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
             color=self.color,
             color=self.color,
             rear_port=rear_port,
             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'),
             ('module_type', 'name'),
         )
         )
 
 
-    def instantiate(self, device):
+    def instantiate(self, **kwargs):
         return RearPort(
         return RearPort(
-            device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
             color=self.color,
             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
         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):
 class LinkTermination(models.Model):
     """
     """
     An abstract model inherited by all models to which a Cable, WirelessLink, or other such link can terminate. Examples
     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')
 @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.
     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')
 @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.
     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')
 @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.
     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')
 @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.
     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')
 @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.
     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')
 @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.
     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')
 @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.
     A pass-through port on the rear of a Device.
     """
     """

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

@@ -26,6 +26,7 @@ __all__ = (
     'DeviceRole',
     'DeviceRole',
     'DeviceType',
     'DeviceType',
     'Manufacturer',
     'Manufacturer',
+    'Module',
     'ModuleType',
     'ModuleType',
     'Platform',
     'Platform',
     'VirtualChassis',
     '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 this is a new Device, instantiate all of the related components per the DeviceType definition
         if is_new:
         if is_new:
             ConsolePort.objects.bulk_create(
             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(
             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(
             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(
             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(
             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(
             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(
             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(
             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(
             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
         # Update Site and Rack assignment for any child Devices
@@ -1008,6 +1009,85 @@ class Device(PrimaryModel, ConfigContextModel):
         return DeviceStatusChoices.colors.get(self.status, 'secondary')
         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
 # Virtual chassis
 #
 #

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

@@ -6,7 +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 .modules import *
 from .power import *
 from .power import *
 from .racks import *
 from .racks import *
 from .sites import *
 from .sites import *

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

@@ -725,26 +725,31 @@ class ModuleBayTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
             'args': [Accessor('device_id')],
         }
         }
     )
     )
+    installed_module = tables.Column(
+        linkify=True,
+        verbose_name='Installed module'
+    )
     tags = TagColumn(
     tags = TagColumn(
         url_name='dcim:modulebay_list'
         url_name='dcim:modulebay_list'
     )
     )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
         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):
 class DeviceModuleBayTable(ModuleBayTable):
     actions = ButtonsColumn(
     actions = ButtonsColumn(
-        model=ModuleBay,
-        buttons=('edit', 'delete')
+        model=DeviceBay,
+        buttons=('edit', 'delete'),
+        prepend_template=MODULEBAY_BUTTONS
     )
     )
 
 
     class Meta(DeviceComponentTable.Meta):
     class Meta(DeviceComponentTable.Meta):
         model = ModuleBay
         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):
 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 %}
 {% 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.constants import *
 from dcim.models import *
 from dcim.models import *
 from ipam.models import ASN, RIR, VLAN
 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 virtualization.models import Cluster, ClusterType
 from wireless.models import WirelessLAN
 from wireless.models import WirelessLAN
 
 
@@ -1105,6 +1105,67 @@ class DeviceTest(APIViewTestCases.APIViewTestCase):
         self.assertHttpStatus(response, status.HTTP_400_BAD_REQUEST)
         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):
 class ConsolePortTest(Mixins.ComponentTraceMixin, APIViewTestCases.APIViewTestCase):
     model = ConsolePort
     model = ConsolePort
     brief_fields = ['_occupied', 'cable', 'device', 'display', 'id', 'name', 'url']
     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 ipam.models import ASN, IPAddress, RIR
 from tenancy.models import Tenant, TenantGroup
 from tenancy.models import Tenant, TenantGroup
 from utilities.choices import ColorChoices
 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 virtualization.models import Cluster, ClusterType
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 from wireless.choices import WirelessChannelChoices, WirelessRoleChoices
 
 
@@ -1648,6 +1648,79 @@ class DeviceTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
 class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = ConsolePort.objects.all()
     queryset = ConsolePort.objects.all()
     filterset = ConsolePortFilterSet
     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)
         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):
 class ConsolePortTestCase(ViewTestCases.DeviceComponentViewTestCase):
     model = ConsolePort
     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>/device-bays/', views.DeviceDeviceBaysView.as_view(), name='device_devicebays'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/inventory/', views.DeviceInventoryView.as_view(), name='device_inventory'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
     path('devices/<int:pk>/config-context/', views.DeviceConfigContextView.as_view(), name='device_configcontext'),
-    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>/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>/lldp-neighbors/', views.DeviceLLDPNeighborsView.as_view(), name='device_lldp_neighbors'),
     path('devices/<int:pk>/config/', views.DeviceConfigView.as_view(), name='device_config'),
     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
     # Console ports
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/', views.ConsolePortListView.as_view(), name='consoleport_list'),
     path('console-ports/add/', views.ConsolePortCreateView.as_view(), name='consoleport_add'),
     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 django.views.generic import View
 
 
 from circuits.models import Circuit
 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.models import ASN, IPAddress, Prefix, Service, VLAN
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from ipam.tables import AssignedIPAddressesTable, InterfaceVLANTable
 from netbox.views import generic
 from netbox.views import generic
@@ -30,7 +30,7 @@ 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, ModuleType, PathEndpoint, Platform, PowerFeed,
+    InventoryItem, Manufacturer, Module, ModuleBay, ModuleBayTemplate, ModuleType, PathEndpoint, Platform, PowerFeed,
     PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation,
     PowerOutlet, PowerOutletTemplate, PowerPanel, PowerPort, PowerPortTemplate, Rack, Location, RackReservation,
     RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
     RackRole, RearPort, RearPortTemplate, Region, Site, SiteGroup, VirtualChassis,
 )
 )
@@ -1629,14 +1629,6 @@ class DeviceConfigContextView(ObjectConfigContextView):
     base_template = 'dcim/device/base.html'
     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):
 class DeviceEditView(generic.ObjectEditView):
     queryset = Device.objects.all()
     queryset = Device.objects.all()
     model_form = forms.DeviceForm
     model_form = forms.DeviceForm
@@ -1685,6 +1677,49 @@ class DeviceBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceTable
     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
 # Console ports
 #
 #

+ 1 - 0
netbox/netbox/navigation_menu.py

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

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

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

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

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