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

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

@@ -21,6 +21,7 @@ __all__ = [
     'NestedInterfaceTemplateSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInventoryItemSerializer',
     'NestedInventoryItemSerializer',
     'NestedInventoryItemRoleSerializer',
     'NestedInventoryItemRoleSerializer',
+    'NestedInventoryItemTemplateSerializer',
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
     'NestedModuleBayTemplateSerializer',
@@ -231,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'name']
         fields = ['id', 'url', 'display', 'name']
 
 
 
 
+class NestedInventoryItemTemplateSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+    class Meta:
+        model = models.InventoryItemTemplate
+        fields = ['id', 'url', 'display', 'name', '_depth']
+
+
 #
 #
 # Devices
 # Devices
 #
 #

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

@@ -447,6 +447,40 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer):
         fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
         fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated']
 
 
 
 
+class InventoryItemTemplateSerializer(ValidatedModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail')
+    device_type = NestedDeviceTypeSerializer()
+    parent = serializers.PrimaryKeyRelatedField(
+        queryset=InventoryItemTemplate.objects.all(),
+        allow_null=True,
+        default=None
+    )
+    role = NestedInventoryItemRoleSerializer(required=False, allow_null=True)
+    manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
+    component_type = ContentTypeField(
+        queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS),
+        required=False,
+        allow_null=True
+    )
+    component = serializers.SerializerMethodField(read_only=True)
+    _depth = serializers.IntegerField(source='level', read_only=True)
+
+    class Meta:
+        model = InventoryItemTemplate
+        fields = [
+            'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id',
+            'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth',
+        ]
+
+    @swagger_serializer_method(serializer_or_field=serializers.DictField)
+    def get_component(self, obj):
+        if obj.component is None:
+            return None
+        serializer = get_serializer_for_model(obj.component, prefix='Nested')
+        context = {'request': self.context['request']}
+        return serializer(obj.component, context=context).data
+
+
 #
 #
 # Devices
 # Devices
 #
 #

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

@@ -31,6 +31,7 @@ router.register('front-port-templates', views.FrontPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
 router.register('rear-port-templates', views.RearPortTemplateViewSet)
 router.register('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
+router.register('inventory-item-templates', views.InventoryItemTemplateViewSet)
 
 
 # Device/modules
 # Device/modules
 router.register('device-roles', views.DeviceRoleViewSet)
 router.register('device-roles', views.DeviceRoleViewSet)

+ 6 - 0
netbox/dcim/api/views.py

@@ -350,6 +350,12 @@ class DeviceBayTemplateViewSet(ModelViewSet):
     filterset_class = filtersets.DeviceBayTemplateFilterSet
     filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 
 
+class InventoryItemTemplateViewSet(ModelViewSet):
+    queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role')
+    serializer_class = serializers.InventoryItemTemplateSerializer
+    filterset_class = filtersets.InventoryItemTemplateFilterSet
+
+
 #
 #
 # Device roles
 # Device roles
 #
 #

+ 12 - 0
netbox/dcim/constants.py

@@ -62,6 +62,18 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80  # Percentage
 # Device components
 # Device components
 #
 #
 
 
+MODULAR_COMPONENT_TEMPLATE_MODELS = Q(
+    app_label='dcim',
+    model__in=(
+        'consoleporttemplate',
+        'consoleserverporttemplate',
+        'frontporttemplate',
+        'interfacetemplate',
+        'poweroutlettemplate',
+        'powerporttemplate',
+        'rearporttemplate',
+    ))
+
 MODULAR_COMPONENT_MODELS = Q(
 MODULAR_COMPONENT_MODELS = Q(
     app_label='dcim',
     app_label='dcim',
     model__in=(
     model__in=(

+ 44 - 0
netbox/dcim/filtersets.py

@@ -40,6 +40,7 @@ __all__ = (
     'InterfaceTemplateFilterSet',
     'InterfaceTemplateFilterSet',
     'InventoryItemFilterSet',
     'InventoryItemFilterSet',
     'InventoryItemRoleFilterSet',
     'InventoryItemRoleFilterSet',
+    'InventoryItemTemplateFilterSet',
     'LocationFilterSet',
     'LocationFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
@@ -687,6 +688,49 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
         fields = ['id', 'name']
         fields = ['id', 'name']
 
 
 
 
+class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet):
+    parent_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=InventoryItemTemplate.objects.all(),
+        label='Parent inventory item (ID)',
+    )
+    manufacturer_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=Manufacturer.objects.all(),
+        label='Manufacturer (ID)',
+    )
+    manufacturer = django_filters.ModelMultipleChoiceFilter(
+        field_name='manufacturer__slug',
+        queryset=Manufacturer.objects.all(),
+        to_field_name='slug',
+        label='Manufacturer (slug)',
+    )
+    role_id = django_filters.ModelMultipleChoiceFilter(
+        queryset=InventoryItemRole.objects.all(),
+        label='Role (ID)',
+    )
+    role = django_filters.ModelMultipleChoiceFilter(
+        field_name='role__slug',
+        queryset=InventoryItemRole.objects.all(),
+        to_field_name='slug',
+        label='Role (slug)',
+    )
+    component_type = ContentTypeFilter()
+    component_id = MultiValueNumberFilter()
+
+    class Meta:
+        model = InventoryItemTemplate
+        fields = ['id', 'name', 'label', 'part_id']
+
+    def search(self, queryset, name, value):
+        if not value.strip():
+            return queryset
+        qs_filter = (
+            Q(name__icontains=value) |
+            Q(part_id__icontains=value) |
+            Q(description__icontains=value)
+        )
+        return queryset.filter(qs_filter)
+
+
 class DeviceRoleFilterSet(OrganizationalModelFilterSet):
 class DeviceRoleFilterSet(OrganizationalModelFilterSet):
     tag = TagFilter()
     tag = TagFilter()
 
 

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

@@ -31,6 +31,7 @@ __all__ = (
     'InterfaceTemplateBulkEditForm',
     'InterfaceTemplateBulkEditForm',
     'InventoryItemBulkEditForm',
     'InventoryItemBulkEditForm',
     'InventoryItemRoleBulkEditForm',
     'InventoryItemRoleBulkEditForm',
+    'InventoryItemTemplateBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBulkEditForm',
     'ModuleBulkEditForm',
@@ -907,6 +908,31 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
         nullable_fields = ('label', 'description')
         nullable_fields = ('label', 'description')
 
 
 
 
+class InventoryItemTemplateBulkEditForm(BulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=InventoryItemTemplate.objects.all(),
+        widget=forms.MultipleHiddenInput()
+    )
+    label = forms.CharField(
+        max_length=64,
+        required=False
+    )
+    description = forms.CharField(
+        required=False
+    )
+    role = DynamicModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description']
+
+
 #
 #
 # Device components
 # Device components
 #
 #

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

@@ -38,6 +38,7 @@ __all__ = (
     'InterfaceTemplateForm',
     'InterfaceTemplateForm',
     'InventoryItemForm',
     'InventoryItemForm',
     'InventoryItemRoleForm',
     'InventoryItemRoleForm',
+    'InventoryItemTemplateForm',
     'LocationForm',
     'LocationForm',
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleForm',
     'ModuleForm',
@@ -1073,6 +1074,48 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm):
         }
         }
 
 
 
 
+class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm):
+    parent = DynamicModelChoiceField(
+        queryset=InventoryItem.objects.all(),
+        required=False,
+        query_params={
+            'device_id': '$device'
+        }
+    )
+    role = DynamicModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
+    component_type = ContentTypeChoiceField(
+        queryset=ContentType.objects.all(),
+        limit_choices_to=MODULAR_COMPONENT_MODELS,
+        required=False,
+        widget=forms.HiddenInput
+    )
+    component_id = forms.IntegerField(
+        required=False,
+        widget=forms.HiddenInput
+    )
+
+    class Meta:
+        model = InventoryItemTemplate
+        fields = [
+            'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
+            'component_type', 'component_id',
+        ]
+        fieldsets = (
+            ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')),
+            ('Hardware', ('manufacturer', 'part_id')),
+        )
+        widgets = {
+            'device_type': forms.HiddenInput(),
+        }
+
+
 #
 #
 # Device components
 # Device components
 #
 #

+ 67 - 18
netbox/dcim/forms/object_import.py

@@ -11,6 +11,7 @@ __all__ = (
     'DeviceTypeImportForm',
     'DeviceTypeImportForm',
     'FrontPortTemplateImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
     'InterfaceTemplateImportForm',
+    'InventoryItemTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleTypeImportForm',
     'ModuleTypeImportForm',
     'PowerOutletTemplateImportForm',
     'PowerOutletTemplateImportForm',
@@ -49,24 +50,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm):
 #
 #
 
 
 class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
 class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm):
-
-    def clean_device_type(self):
-        # Limit fields referencing other components to the parent DeviceType
-        if data := self.cleaned_data['device_type']:
-            for field_name, field in self.fields.items():
-                if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']:
-                    field.queryset = field.queryset.filter(device_type=data)
-
-        return data
-
-    def clean_module_type(self):
-        # Limit fields referencing other components to the parent ModuleType
-        if data := self.cleaned_data['module_type']:
-            for field_name, field in self.fields.items():
-                if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']:
-                    field.queryset = field.queryset.filter(module_type=data)
-
-        return data
+    pass
 
 
 
 
 class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
 class ConsolePortTemplateImportForm(ComponentTemplateImportForm):
@@ -109,6 +93,20 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
             'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
             'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description',
         ]
         ]
 
 
+    def clean_device_type(self):
+        if device_type := self.cleaned_data['device_type']:
+            power_port = self.fields['power_port']
+            power_port.queryset = power_port.queryset.filter(device_type=device_type)
+
+        return device_type
+
+    def clean_module_type(self):
+        if module_type := self.cleaned_data['module_type']:
+            power_port = self.fields['power_port']
+            power_port.queryset = power_port.queryset.filter(module_type=module_type)
+
+        return module_type
+
 
 
 class InterfaceTemplateImportForm(ComponentTemplateImportForm):
 class InterfaceTemplateImportForm(ComponentTemplateImportForm):
     type = forms.ChoiceField(
     type = forms.ChoiceField(
@@ -131,6 +129,20 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
         to_field_name='name'
         to_field_name='name'
     )
     )
 
 
+    def clean_device_type(self):
+        if device_type := self.cleaned_data['device_type']:
+            rear_port = self.fields['rear_port']
+            rear_port.queryset = rear_port.queryset.filter(device_type=device_type)
+
+        return device_type
+
+    def clean_module_type(self):
+        if module_type := self.cleaned_data['module_type']:
+            rear_port = self.fields['rear_port']
+            rear_port.queryset = rear_port.queryset.filter(module_type=module_type)
+
+        return module_type
+
     class Meta:
     class Meta:
         model = FrontPortTemplate
         model = FrontPortTemplate
         fields = [
         fields = [
@@ -166,3 +178,40 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
         fields = [
         fields = [
             'device_type', 'name', 'label', 'description',
             'device_type', 'name', 'label', 'description',
         ]
         ]
+
+
+class InventoryItemTemplateImportForm(ComponentTemplateImportForm):
+    parent = forms.ModelChoiceField(
+        queryset=InventoryItemTemplate.objects.all(),
+        required=False
+    )
+    role = forms.ModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+    manufacturer = forms.ModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        to_field_name='name',
+        required=False
+    )
+
+    class Meta:
+        model = InventoryItemTemplate
+        fields = [
+            'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description',
+        ]
+
+    def clean_device_type(self):
+        if device_type := self.cleaned_data['device_type']:
+            parent = self.fields['parent']
+            parent.queryset = parent.queryset.filter(device_type=device_type)
+
+        return device_type
+
+    def clean_module_type(self):
+        if module_type := self.cleaned_data['module_type']:
+            parent = self.fields['parent']
+            parent.queryset = parent.queryset.filter(module_type=module_type)
+
+        return module_type

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

@@ -53,6 +53,9 @@ class DCIMQuery(graphene.ObjectType):
     inventory_item_role = ObjectField(InventoryItemRoleType)
     inventory_item_role = ObjectField(InventoryItemRoleType)
     inventory_item_role_list = ObjectListField(InventoryItemRoleType)
     inventory_item_role_list = ObjectListField(InventoryItemRoleType)
 
 
+    inventory_item_template = ObjectField(InventoryItemTemplateType)
+    inventory_item_template_list = ObjectListField(InventoryItemTemplateType)
+
     location = ObjectField(LocationType)
     location = ObjectField(LocationType)
     location_list = ObjectListField(LocationType)
     location_list = ObjectListField(LocationType)
 
 

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

@@ -26,6 +26,7 @@ __all__ = (
     'InterfaceTemplateType',
     'InterfaceTemplateType',
     'InventoryItemType',
     'InventoryItemType',
     'InventoryItemRoleType',
     'InventoryItemRoleType',
+    'InventoryItemTemplateType',
     'LocationType',
     'LocationType',
     'ManufacturerType',
     'ManufacturerType',
     'ModuleType',
     'ModuleType',
@@ -172,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.DeviceBayTemplateFilterSet
         filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
 
 
+class InventoryItemTemplateType(ComponentTemplateObjectType):
+
+    class Meta:
+        model = models.InventoryItemTemplate
+        fields = '__all__'
+        filterset_class = filtersets.InventoryItemTemplateFilterSet
+
+
 class DeviceRoleType(OrganizationalObjectType):
 class DeviceRoleType(OrganizationalObjectType):
 
 
     class Meta:
     class Meta:

+ 43 - 0
netbox/dcim/migrations/0148_inventoryitem_templates.py

@@ -0,0 +1,43 @@
+from django.db import migrations, models
+import django.db.models.deletion
+import mptt.fields
+import utilities.fields
+import utilities.ordering
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('contenttypes', '0002_remove_content_type_name'),
+        ('dcim', '0147_inventoryitem_component'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='InventoryItemTemplate',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=64)),
+                ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)),
+                ('label', models.CharField(blank=True, max_length=64)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('component_id', models.PositiveBigIntegerField(blank=True, null=True)),
+                ('part_id', models.CharField(blank=True, max_length=50)),
+                ('lft', models.PositiveIntegerField(editable=False)),
+                ('rght', models.PositiveIntegerField(editable=False)),
+                ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)),
+                ('level', models.PositiveIntegerField(editable=False)),
+                ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')),
+                ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitemtemplates', to='dcim.devicetype')),
+                ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')),
+                ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')),
+                ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')),
+            ],
+            options={
+                'ordering': ('device_type__id', 'parent__id', '_name'),
+                'unique_together': {('device_type', 'parent', 'name')},
+            },
+        ),
+    ]

+ 110 - 10
netbox/dcim/models/device_component_templates.py

@@ -1,15 +1,20 @@
+from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation
+from django.contrib.contenttypes.models import ContentType
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.exceptions import ObjectDoesNotExist, ValidationError
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.core.validators import MaxValueValidator, MinValueValidator
 from django.db import models
 from django.db import models
+from mptt.models import MPTTModel, TreeForeignKey
 
 
 from dcim.choices import *
 from dcim.choices import *
 from dcim.constants import *
 from dcim.constants import *
 from extras.utils import extras_features
 from extras.utils import extras_features
 from netbox.models import ChangeLoggedModel
 from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
+from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
 from .device_components import (
 from .device_components import (
-    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort,
+    ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort,
+    RearPort,
 )
 )
 
 
 
 
@@ -19,6 +24,7 @@ __all__ = (
     'DeviceBayTemplate',
     'DeviceBayTemplate',
     'FrontPortTemplate',
     'FrontPortTemplate',
     'InterfaceTemplate',
     'InterfaceTemplate',
+    'InventoryItemTemplate',
     'ModuleBayTemplate',
     'ModuleBayTemplate',
     'PowerOutletTemplate',
     'PowerOutletTemplate',
     'PowerPortTemplate',
     'PowerPortTemplate',
@@ -140,6 +146,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
         blank=True
         blank=True
     )
     )
 
 
+    component_model = ConsolePort
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -148,7 +156,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
         )
         )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        return ConsolePort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -167,6 +175,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
         blank=True
         blank=True
     )
     )
 
 
+    component_model = ConsoleServerPort
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -175,7 +185,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
         )
         )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        return ConsoleServerPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -206,6 +216,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         help_text="Allocated power draw (watts)"
         help_text="Allocated power draw (watts)"
     )
     )
 
 
+    component_model = PowerPort
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -214,7 +226,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         )
         )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        return PowerPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -257,6 +269,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         help_text="Phase (for three-phase feeds)"
         help_text="Phase (for three-phase feeds)"
     )
     )
 
 
+    component_model = PowerOutlet
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -283,7 +297,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs)
             power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs)
         else:
         else:
             power_port = None
             power_port = None
-        return PowerOutlet(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -314,6 +328,8 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         verbose_name='Management only'
         verbose_name='Management only'
     )
     )
 
 
+    component_model = Interface
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -322,7 +338,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         )
         )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        return Interface(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -356,6 +372,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         ]
         ]
     )
     )
 
 
+    component_model = FrontPort
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -391,7 +409,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs)
             rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs)
         else:
         else:
             rear_port = None
             rear_port = None
-        return FrontPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -422,6 +440,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
         ]
         ]
     )
     )
 
 
+    component_model = RearPort
+
     class Meta:
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
         unique_together = (
@@ -430,7 +450,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
         )
         )
 
 
     def instantiate(self, **kwargs):
     def instantiate(self, **kwargs):
-        return RearPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             label=self.label,
             type=self.type,
             type=self.type,
@@ -451,12 +471,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
         help_text='Identifier to reference when renaming installed components'
         help_text='Identifier to reference when renaming installed components'
     )
     )
 
 
+    component_model = ModuleBay
+
     class Meta:
     class Meta:
         ordering = ('device_type', '_name')
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
         unique_together = ('device_type', 'name')
 
 
     def instantiate(self, device):
     def instantiate(self, device):
-        return ModuleBay(
+        return self.component_model(
             device=device,
             device=device,
             name=self.name,
             name=self.name,
             label=self.label,
             label=self.label,
@@ -469,12 +491,14 @@ class DeviceBayTemplate(ComponentTemplateModel):
     """
     """
     A template for a DeviceBay to be created for a new parent Device.
     A template for a DeviceBay to be created for a new parent Device.
     """
     """
+    component_model = DeviceBay
+
     class Meta:
     class Meta:
         ordering = ('device_type', '_name')
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
         unique_together = ('device_type', 'name')
 
 
     def instantiate(self, device):
     def instantiate(self, device):
-        return DeviceBay(
+        return self.component_model(
             device=device,
             device=device,
             name=self.name,
             name=self.name,
             label=self.label
             label=self.label
@@ -485,3 +509,79 @@ class DeviceBayTemplate(ComponentTemplateModel):
             raise ValidationError(
             raise ValidationError(
                 f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
                 f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays."
             )
             )
+
+
+@extras_features('webhooks')
+class InventoryItemTemplate(MPTTModel, ComponentTemplateModel):
+    """
+    A template for an InventoryItem to be created for a new parent Device.
+    """
+    parent = TreeForeignKey(
+        to='self',
+        on_delete=models.CASCADE,
+        related_name='child_items',
+        blank=True,
+        null=True,
+        db_index=True
+    )
+    component_type = models.ForeignKey(
+        to=ContentType,
+        limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS,
+        on_delete=models.PROTECT,
+        related_name='+',
+        blank=True,
+        null=True
+    )
+    component_id = models.PositiveBigIntegerField(
+        blank=True,
+        null=True
+    )
+    component = GenericForeignKey(
+        ct_field='component_type',
+        fk_field='component_id'
+    )
+    role = models.ForeignKey(
+        to='dcim.InventoryItemRole',
+        on_delete=models.PROTECT,
+        related_name='inventory_item_templates',
+        blank=True,
+        null=True
+    )
+    manufacturer = models.ForeignKey(
+        to='dcim.Manufacturer',
+        on_delete=models.PROTECT,
+        related_name='inventory_item_templates',
+        blank=True,
+        null=True
+    )
+    part_id = models.CharField(
+        max_length=50,
+        verbose_name='Part ID',
+        blank=True,
+        help_text='Manufacturer-assigned part identifier'
+    )
+
+    objects = TreeManager()
+    component_model = InventoryItem
+
+    class Meta:
+        ordering = ('device_type__id', 'parent__id', '_name')
+        unique_together = ('device_type', 'parent', 'name')
+
+    def instantiate(self, **kwargs):
+        parent = InventoryItemTemplate.objects.get(name=self.parent.name, **kwargs) if self.parent else None
+        if self.component:
+            model = self.component.component_model
+            component = model.objects.get(name=self.component.name, **kwargs)
+        else:
+            component = None
+        return self.component_model(
+            parent=parent,
+            name=self.name,
+            label=self.label,
+            component=component,
+            role=self.role,
+            manufacturer=self.manufacturer,
+            part_id=self.part_id,
+            **kwargs
+        )

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

@@ -933,6 +933,9 @@ class Device(PrimaryModel, ConfigContextModel):
             DeviceBay.objects.bulk_create(
             DeviceBay.objects.bulk_create(
                 [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()]
                 [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()]
             )
             )
+            # Avoid bulk_create to handle MPTT
+            for x in self.device_type.inventoryitemtemplates.all():
+                x.instantiate(device=self).save()
 
 
         # Update Site and Rack assignment for any child Devices
         # Update Site and Rack assignment for any child Devices
         devices = Device.objects.filter(parent_bay__device=self)
         devices = Device.objects.filter(parent_bay__device=self)

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

@@ -1,8 +1,9 @@
 import django_tables2 as tables
 import django_tables2 as tables
+from django_tables2.utils import Accessor
 
 
 from dcim.models import (
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
-    Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 )
 from utilities.tables import (
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
     BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
@@ -15,6 +16,7 @@ __all__ = (
     'DeviceTypeTable',
     'DeviceTypeTable',
     'FrontPortTemplateTable',
     'FrontPortTemplateTable',
     'InterfaceTemplateTable',
     'InterfaceTemplateTable',
+    'InventoryItemTemplateTable',
     'ManufacturerTable',
     'ManufacturerTable',
     'ModuleBayTemplateTable',
     'ModuleBayTemplateTable',
     'PowerOutletTemplateTable',
     'PowerOutletTemplateTable',
@@ -223,3 +225,25 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
         model = DeviceBayTemplate
         model = DeviceBayTemplate
         fields = ('pk', 'name', 'label', 'description', 'actions')
         fields = ('pk', 'name', 'label', 'description', 'actions')
         empty_text = "None"
         empty_text = "None"
+
+
+class InventoryItemTemplateTable(ComponentTemplateTable):
+    actions = ButtonsColumn(
+        model=InventoryItemTemplate,
+        buttons=('edit', 'delete')
+    )
+    role = tables.Column(
+        linkify=True
+    )
+    manufacturer = tables.Column(
+        linkify=True
+    )
+    component = tables.Column(
+        accessor=Accessor('component'),
+        orderable=False
+    )
+
+    class Meta(ComponentTemplateTable.Meta):
+        model = InventoryItemTemplate
+        fields = ('pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions')
+        empty_text = "None"

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

@@ -897,6 +897,57 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase):
         ]
         ]
 
 
 
 
+class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase):
+    model = InventoryItemTemplate
+    brief_fields = ['_depth', 'display', 'id', 'name', 'url']
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1')
+        devicetype = DeviceType.objects.create(
+            manufacturer=manufacturer,
+            model='Device Type 1',
+            slug='device-type-1'
+        )
+        role = InventoryItemRole.objects.create(name='Inventory Item Role 1', slug='inventory-item-role-1')
+
+        inventory_item_templates = (
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturer, role=role),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturer, role=role),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturer, role=role),
+            InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 4', manufacturer=manufacturer, role=role),
+        )
+        for item in inventory_item_templates:
+            item.save()
+
+        cls.create_data = [
+            {
+                'device_type': devicetype.pk,
+                'name': 'Inventory Item Template 5',
+                'manufacturer': manufacturer.pk,
+                'role': role.pk,
+                'parent': inventory_item_templates[3].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Inventory Item Template 6',
+                'manufacturer': manufacturer.pk,
+                'role': role.pk,
+                'parent': inventory_item_templates[3].pk,
+            },
+            {
+                'device_type': devicetype.pk,
+                'name': 'Inventory Item Template 7',
+                'manufacturer': manufacturer.pk,
+                'role': role.pk,
+                'parent': inventory_item_templates[3].pk,
+            },
+        ]
+
+
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
 class DeviceRoleTest(APIViewTestCases.APIViewTestCase):
     model = DeviceRole
     model = DeviceRole
     brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']
     brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count']

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

@@ -1214,6 +1214,86 @@ class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
 
 
 
 
+class InventoryItemTemplateTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = InventoryItemTemplate.objects.all()
+    filterset = InventoryItemTemplateFilterSet
+
+    @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)
+
+        device_types = (
+            DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1'),
+            DeviceType(manufacturer=manufacturers[0], model='Model 2', slug='model-2'),
+            DeviceType(manufacturer=manufacturers[0], model='Model 3', slug='model-3'),
+        )
+        DeviceType.objects.bulk_create(device_types)
+
+        inventory_item_roles = (
+            InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
+            InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
+            InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3'),
+        )
+        InventoryItemRole.objects.bulk_create(inventory_item_roles)
+
+        inventory_item_templates = (
+            InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1', label='A', role=inventory_item_roles[0], manufacturer=manufacturers[0], part_id='1001'),
+            InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2', label='B', role=inventory_item_roles[1], manufacturer=manufacturers[1], part_id='1002'),
+            InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3', label='C', role=inventory_item_roles[2], manufacturer=manufacturers[2], part_id='1003'),
+        )
+        for item in inventory_item_templates:
+            item.save()
+
+        child_inventory_item_templates = (
+            InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1A', parent=inventory_item_templates[0]),
+            InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2A', parent=inventory_item_templates[1]),
+            InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3A', parent=inventory_item_templates[2]),
+        )
+        for item in child_inventory_item_templates:
+            item.save()
+
+    def test_name(self):
+        params = {'name': ['Inventory Item 1', 'Inventory Item 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_devicetype_id(self):
+        device_types = DeviceType.objects.all()[:2]
+        params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4)
+
+    def test_label(self):
+        params = {'label': ['A', 'B']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_part_id(self):
+        params = {'part_id': ['1001', '1002']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_parent_id(self):
+        parent_items = InventoryItemTemplate.objects.filter(parent__isnull=True)[:2]
+        params = {'parent_id': [parent_items[0].pk, parent_items[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_role(self):
+        roles = InventoryItemRole.objects.all()[:2]
+        params = {'role_id': [roles[0].pk, roles[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'role': [roles[0].slug, roles[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_manufacturer(self):
+        manufacturers = Manufacturer.objects.all()[:2]
+        params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+        params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
 class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = DeviceRole.objects.all()
     queryset = DeviceRole.objects.all()
     filterset = DeviceRoleFilterSet
     filterset = DeviceRoleFilterSet

+ 70 - 2
netbox/dcim/tests/test_views.py

@@ -580,6 +580,20 @@ class DeviceTypeTestCase(
         url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk})
         url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk})
         self.assertHttpStatus(self.client.get(url), 200)
         self.assertHttpStatus(self.client.get(url), 200)
 
 
+    @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
+    def test_devicetype_inventoryitems(self):
+        devicetype = DeviceType.objects.first()
+        inventory_items = (
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'),
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'),
+            DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'),
+        )
+        for inventory_item in inventory_items:
+            inventory_item.save()
+
+        url = reverse('dcim:devicetype_inventoryitems', kwargs={'pk': devicetype.pk})
+        self.assertHttpStatus(self.client.get(url), 200)
+
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     @override_settings(EXEMPT_VIEW_PERMISSIONS=['*'])
     def test_import_objects(self):
     def test_import_objects(self):
         """
         """
@@ -659,6 +673,13 @@ device-bays:
   - name: Device Bay 1
   - name: Device Bay 1
   - name: Device Bay 2
   - name: Device Bay 2
   - name: Device Bay 3
   - name: Device Bay 3
+inventory-items:
+  - name: Inventory Item 1
+    manufacturer: Generic
+  - name: Inventory Item 2
+    manufacturer: Generic
+  - name: Inventory Item 3
+    manufacturer: Generic
 """
 """
 
 
         # Create the manufacturer
         # Create the manufacturer
@@ -677,6 +698,7 @@ device-bays:
             'dcim.add_rearporttemplate',
             'dcim.add_rearporttemplate',
             'dcim.add_modulebaytemplate',
             'dcim.add_modulebaytemplate',
             'dcim.add_devicebaytemplate',
             'dcim.add_devicebaytemplate',
+            'dcim.add_inventoryitemtemplate',
         )
         )
 
 
         form_data = {
         form_data = {
@@ -729,13 +751,17 @@ device-bays:
         self.assertEqual(fp1.rear_port_position, 1)
         self.assertEqual(fp1.rear_port_position, 1)
 
 
         self.assertEqual(device_type.modulebaytemplates.count(), 3)
         self.assertEqual(device_type.modulebaytemplates.count(), 3)
-        db1 = ModuleBayTemplate.objects.first()
-        self.assertEqual(db1.name, 'Module Bay 1')
+        mb1 = ModuleBayTemplate.objects.first()
+        self.assertEqual(mb1.name, 'Module Bay 1')
 
 
         self.assertEqual(device_type.devicebaytemplates.count(), 3)
         self.assertEqual(device_type.devicebaytemplates.count(), 3)
         db1 = DeviceBayTemplate.objects.first()
         db1 = DeviceBayTemplate.objects.first()
         self.assertEqual(db1.name, 'Device Bay 1')
         self.assertEqual(db1.name, 'Device Bay 1')
 
 
+        self.assertEqual(device_type.inventoryitemtemplates.count(), 3)
+        ii1 = InventoryItemTemplate.objects.first()
+        self.assertEqual(ii1.name, 'Inventory Item 1')
+
     def test_export_objects(self):
     def test_export_objects(self):
         url = reverse('dcim:devicetype_list')
         url = reverse('dcim:devicetype_list')
         self.add_permissions('dcim.view_devicetype')
         self.add_permissions('dcim.view_devicetype')
@@ -1393,6 +1419,48 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas
         }
         }
 
 
 
 
+class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase):
+    model = InventoryItemTemplate
+
+    @classmethod
+    def setUpTestData(cls):
+        manufacturers = (
+            Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
+            Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
+        )
+        Manufacturer.objects.bulk_create(manufacturers)
+
+        devicetypes = (
+            DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'),
+            DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'),
+        )
+        DeviceType.objects.bulk_create(devicetypes)
+
+        inventory_item_templates = (
+            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]),
+            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]),
+            InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]),
+        )
+        for item in inventory_item_templates:
+            item.save()
+
+        cls.form_data = {
+            'device_type': devicetypes[1].pk,
+            'name': 'Inventory Item Template X',
+            'manufacturer': manufacturers[1].pk,
+        }
+
+        cls.bulk_create_data = {
+            'device_type': devicetypes[1].pk,
+            'name_pattern': 'Inventory Item Template [4-6]',
+            'manufacturer': manufacturers[1].pk,
+        }
+
+        cls.bulk_edit_data = {
+            'description': 'Foo bar',
+        }
+
+
 class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
 class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
     model = DeviceRole
     model = DeviceRole
 
 

+ 10 - 1
netbox/dcim/urls.py

@@ -115,6 +115,7 @@ urlpatterns = [
     path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
     path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
     path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
     path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
     path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
     path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
+    path('device-types/<int:pk>/inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
     path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
@@ -203,7 +204,7 @@ urlpatterns = [
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
     path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
     path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
     path('device-bay-templates/<int:pk>/delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'),
 
 
-    # Device bay templates
+    # Module bay templates
     path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'),
     path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'),
     path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'),
     path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'),
     path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'),
     path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'),
@@ -211,6 +212,14 @@ urlpatterns = [
     path('module-bay-templates/<int:pk>/edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'),
     path('module-bay-templates/<int:pk>/edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'),
     path('module-bay-templates/<int:pk>/delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'),
     path('module-bay-templates/<int:pk>/delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'),
 
 
+    # Inventory item templates
+    path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'),
+    path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'),
+    path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'),
+    path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'),
+    path('inventory-item-templates/<int:pk>/edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'),
+    path('inventory-item-templates/<int:pk>/delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'),
+
     # Device roles
     # Device roles
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),
     path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'),

+ 54 - 0
netbox/dcim/views.py

@@ -869,6 +869,13 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView):
     viewname = 'dcim:devicetype_devicebays'
     viewname = 'dcim:devicetype_devicebays'
 
 
 
 
+class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
+    child_model = InventoryItemTemplate
+    table = tables.InventoryItemTemplateTable
+    filterset = filtersets.InventoryItemTemplateFilterSet
+    viewname = 'dcim:devicetype_inventoryitems'
+
+
 class DeviceTypeEditView(generic.ObjectEditView):
 class DeviceTypeEditView(generic.ObjectEditView):
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeForm
     model_form = forms.DeviceTypeForm
@@ -890,6 +897,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         'dcim.add_rearporttemplate',
         'dcim.add_rearporttemplate',
         'dcim.add_modulebaytemplate',
         'dcim.add_modulebaytemplate',
         'dcim.add_devicebaytemplate',
         'dcim.add_devicebaytemplate',
+        'dcim.add_inventoryitemtemplate',
     ]
     ]
     queryset = DeviceType.objects.all()
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeImportForm
     model_form = forms.DeviceTypeImportForm
@@ -903,6 +911,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         ('front-ports', forms.FrontPortTemplateImportForm),
         ('front-ports', forms.FrontPortTemplateImportForm),
         ('module-bays', forms.ModuleBayTemplateImportForm),
         ('module-bays', forms.ModuleBayTemplateImportForm),
         ('device-bays', forms.DeviceBayTemplateImportForm),
         ('device-bays', forms.DeviceBayTemplateImportForm),
+        ('inventory-items', forms.InventoryItemTemplateImportForm),
     ))
     ))
 
 
     def prep_related_object_data(self, parent, data):
     def prep_related_object_data(self, parent, data):
@@ -1362,6 +1371,51 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
     table = tables.DeviceBayTemplateTable
     table = tables.DeviceBayTemplateTable
 
 
 
 
+#
+# Inventory item templates
+#
+
+class InventoryItemTemplateCreateView(generic.ComponentCreateView):
+    queryset = InventoryItemTemplate.objects.all()
+    form = forms.DeviceTypeComponentCreateForm
+    model_form = forms.InventoryItemTemplateForm
+
+    def alter_object(self, instance, request):
+        # Set component (if any)
+        component_type = request.GET.get('component_type')
+        component_id = request.GET.get('component_id')
+
+        if component_type and component_id:
+            content_type = get_object_or_404(ContentType, pk=component_type)
+            instance.component = get_object_or_404(content_type.model_class(), pk=component_id)
+
+        return instance
+
+
+class InventoryItemTemplateEditView(generic.ObjectEditView):
+    queryset = InventoryItemTemplate.objects.all()
+    model_form = forms.InventoryItemTemplateForm
+
+
+class InventoryItemTemplateDeleteView(generic.ObjectDeleteView):
+    queryset = InventoryItemTemplate.objects.all()
+
+
+class InventoryItemTemplateBulkEditView(generic.BulkEditView):
+    queryset = InventoryItemTemplate.objects.all()
+    table = tables.InventoryItemTemplateTable
+    form = forms.InventoryItemTemplateBulkEditForm
+
+
+class InventoryItemTemplateBulkRenameView(generic.BulkRenameView):
+    queryset = InventoryItemTemplate.objects.all()
+
+
+class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView):
+    queryset = InventoryItemTemplate.objects.all()
+    table = tables.InventoryItemTemplateTable
+
+
 #
 #
 # Device roles
 # Device roles
 #
 #

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

@@ -44,6 +44,9 @@
         {% if perms.dcim.add_devicebaytemplate %}
         {% if perms.dcim.add_devicebaytemplate %}
           <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays</a></li>
           <li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays</a></li>
         {% endif %}
         {% endif %}
+        {% if perms.dcim.add_inventoryitemtemplate %}
+          <li><a class="dropdown-item" href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_inventoryitems' pk=object.pk %}">Inventory Items</a></li>
+        {% endif %}
       </ul>
       </ul>
     </div>
     </div>
   {% endif %}
   {% endif %}
@@ -127,4 +130,12 @@
             </li>
             </li>
         {% endif %}
         {% endif %}
     {% endwith %}
     {% endwith %}
+
+    {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %}
+        {% if active_tab == tab_name or inventoryitem_count %}
+            <li role="presentation" class="nav-item">
+                <a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_inventoryitems' pk=object.pk %}">Inventory Items {% badge inventoryitem_count %}</a>
+            </li>
+        {% endif %}
+    {% endwith %}
 {% endblock %}
 {% endblock %}