jeremystretch 4 سال پیش
والد
کامیت
4c15f4a84f

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

@@ -21,6 +21,7 @@ __all__ = [
     'NestedInterfaceTemplateSerializer',
     'NestedInventoryItemSerializer',
     'NestedInventoryItemRoleSerializer',
+    'NestedInventoryItemTemplateSerializer',
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
@@ -231,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer):
         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
 #

+ 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']
 
 
+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
 #

+ 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('module-bay-templates', views.ModuleBayTemplateViewSet)
 router.register('device-bay-templates', views.DeviceBayTemplateViewSet)
+router.register('inventory-item-templates', views.InventoryItemTemplateViewSet)
 
 # Device/modules
 router.register('device-roles', views.DeviceRoleViewSet)

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

@@ -350,6 +350,12 @@ class DeviceBayTemplateViewSet(ModelViewSet):
     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
 #

+ 12 - 0
netbox/dcim/constants.py

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

+ 44 - 0
netbox/dcim/filtersets.py

@@ -40,6 +40,7 @@ __all__ = (
     'InterfaceTemplateFilterSet',
     'InventoryItemFilterSet',
     'InventoryItemRoleFilterSet',
+    'InventoryItemTemplateFilterSet',
     'LocationFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
@@ -687,6 +688,49 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent
         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):
     tag = TagFilter()
 

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

@@ -31,6 +31,7 @@ __all__ = (
     'InterfaceTemplateBulkEditForm',
     'InventoryItemBulkEditForm',
     'InventoryItemRoleBulkEditForm',
+    'InventoryItemTemplateBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBulkEditForm',
@@ -907,6 +908,31 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm):
         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
 #

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

@@ -38,6 +38,7 @@ __all__ = (
     'InterfaceTemplateForm',
     'InventoryItemForm',
     'InventoryItemRoleForm',
+    'InventoryItemTemplateForm',
     'LocationForm',
     'ManufacturerForm',
     '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
 #

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

@@ -11,6 +11,7 @@ __all__ = (
     'DeviceTypeImportForm',
     'FrontPortTemplateImportForm',
     'InterfaceTemplateImportForm',
+    'InventoryItemTemplateImportForm',
     'ModuleBayTemplateImportForm',
     'ModuleTypeImportForm',
     'PowerOutletTemplateImportForm',
@@ -49,24 +50,7 @@ class ModuleTypeImportForm(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):
@@ -109,6 +93,20 @@ class PowerOutletTemplateImportForm(ComponentTemplateImportForm):
             '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):
     type = forms.ChoiceField(
@@ -131,6 +129,20 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm):
         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:
         model = FrontPortTemplate
         fields = [
@@ -166,3 +178,40 @@ class DeviceBayTemplateImportForm(ComponentTemplateImportForm):
         fields = [
             '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_list = ObjectListField(InventoryItemRoleType)
 
+    inventory_item_template = ObjectField(InventoryItemTemplateType)
+    inventory_item_template_list = ObjectListField(InventoryItemTemplateType)
+
     location = ObjectField(LocationType)
     location_list = ObjectListField(LocationType)
 

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

@@ -26,6 +26,7 @@ __all__ = (
     'InterfaceTemplateType',
     'InventoryItemType',
     'InventoryItemRoleType',
+    'InventoryItemTemplateType',
     'LocationType',
     'ManufacturerType',
     'ModuleType',
@@ -172,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType):
         filterset_class = filtersets.DeviceBayTemplateFilterSet
 
 
+class InventoryItemTemplateType(ComponentTemplateObjectType):
+
+    class Meta:
+        model = models.InventoryItemTemplate
+        fields = '__all__'
+        filterset_class = filtersets.InventoryItemTemplateFilterSet
+
+
 class DeviceRoleType(OrganizationalObjectType):
 
     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.validators import MaxValueValidator, MinValueValidator
 from django.db import models
+from mptt.models import MPTTModel, TreeForeignKey
 
 from dcim.choices import *
 from dcim.constants import *
 from extras.utils import extras_features
 from netbox.models import ChangeLoggedModel
 from utilities.fields import ColorField, NaturalOrderingField
+from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 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',
     'FrontPortTemplate',
     'InterfaceTemplate',
+    'InventoryItemTemplate',
     'ModuleBayTemplate',
     'PowerOutletTemplate',
     'PowerPortTemplate',
@@ -140,6 +146,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
         blank=True
     )
 
+    component_model = ConsolePort
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -148,7 +156,7 @@ class ConsolePortTemplate(ModularComponentTemplateModel):
         )
 
     def instantiate(self, **kwargs):
-        return ConsolePort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -167,6 +175,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
         blank=True
     )
 
+    component_model = ConsoleServerPort
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -175,7 +185,7 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel):
         )
 
     def instantiate(self, **kwargs):
-        return ConsoleServerPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -206,6 +216,8 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         help_text="Allocated power draw (watts)"
     )
 
+    component_model = PowerPort
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -214,7 +226,7 @@ class PowerPortTemplate(ModularComponentTemplateModel):
         )
 
     def instantiate(self, **kwargs):
-        return PowerPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -257,6 +269,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
         help_text="Phase (for three-phase feeds)"
     )
 
+    component_model = PowerOutlet
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -283,7 +297,7 @@ class PowerOutletTemplate(ModularComponentTemplateModel):
             power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs)
         else:
             power_port = None
-        return PowerOutlet(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -314,6 +328,8 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         verbose_name='Management only'
     )
 
+    component_model = Interface
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -322,7 +338,7 @@ class InterfaceTemplate(ModularComponentTemplateModel):
         )
 
     def instantiate(self, **kwargs):
-        return Interface(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -356,6 +372,8 @@ class FrontPortTemplate(ModularComponentTemplateModel):
         ]
     )
 
+    component_model = FrontPort
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -391,7 +409,7 @@ class FrontPortTemplate(ModularComponentTemplateModel):
             rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs)
         else:
             rear_port = None
-        return FrontPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -422,6 +440,8 @@ class RearPortTemplate(ModularComponentTemplateModel):
         ]
     )
 
+    component_model = RearPort
+
     class Meta:
         ordering = ('device_type', 'module_type', '_name')
         unique_together = (
@@ -430,7 +450,7 @@ class RearPortTemplate(ModularComponentTemplateModel):
         )
 
     def instantiate(self, **kwargs):
-        return RearPort(
+        return self.component_model(
             name=self.resolve_name(kwargs.get('module')),
             label=self.label,
             type=self.type,
@@ -451,12 +471,14 @@ class ModuleBayTemplate(ComponentTemplateModel):
         help_text='Identifier to reference when renaming installed components'
     )
 
+    component_model = ModuleBay
+
     class Meta:
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
     def instantiate(self, device):
-        return ModuleBay(
+        return self.component_model(
             device=device,
             name=self.name,
             label=self.label,
@@ -469,12 +491,14 @@ class DeviceBayTemplate(ComponentTemplateModel):
     """
     A template for a DeviceBay to be created for a new parent Device.
     """
+    component_model = DeviceBay
+
     class Meta:
         ordering = ('device_type', '_name')
         unique_together = ('device_type', 'name')
 
     def instantiate(self, device):
-        return DeviceBay(
+        return self.component_model(
             device=device,
             name=self.name,
             label=self.label
@@ -485,3 +509,79 @@ class DeviceBayTemplate(ComponentTemplateModel):
             raise ValidationError(
                 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(
                 [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
         devices = Device.objects.filter(parent_bay__device=self)

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

@@ -1,8 +1,9 @@
 import django_tables2 as tables
+from django_tables2.utils import Accessor
 
 from dcim.models import (
     ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate,
-    Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
+    InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate,
 )
 from utilities.tables import (
     BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn,
@@ -15,6 +16,7 @@ __all__ = (
     'DeviceTypeTable',
     'FrontPortTemplateTable',
     'InterfaceTemplateTable',
+    'InventoryItemTemplateTable',
     'ManufacturerTable',
     'ModuleBayTemplateTable',
     'PowerOutletTemplateTable',
@@ -223,3 +225,25 @@ class DeviceBayTemplateTable(ComponentTemplateTable):
         model = DeviceBayTemplate
         fields = ('pk', 'name', 'label', 'description', 'actions')
         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):
     model = DeviceRole
     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)
 
 
+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):
     queryset = DeviceRole.objects.all()
     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})
         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=['*'])
     def test_import_objects(self):
         """
@@ -659,6 +673,13 @@ device-bays:
   - name: Device Bay 1
   - name: Device Bay 2
   - 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
@@ -677,6 +698,7 @@ device-bays:
             'dcim.add_rearporttemplate',
             'dcim.add_modulebaytemplate',
             'dcim.add_devicebaytemplate',
+            'dcim.add_inventoryitemtemplate',
         )
 
         form_data = {
@@ -729,13 +751,17 @@ device-bays:
         self.assertEqual(fp1.rear_port_position, 1)
 
         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)
         db1 = DeviceBayTemplate.objects.first()
         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):
         url = reverse('dcim:devicetype_list')
         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):
     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>/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>/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>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
     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>/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/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'),
     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>/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
     path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'),
     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'
 
 
+class DeviceTypeInventoryItemsView(DeviceTypeComponentsView):
+    child_model = InventoryItemTemplate
+    table = tables.InventoryItemTemplateTable
+    filterset = filtersets.InventoryItemTemplateFilterSet
+    viewname = 'dcim:devicetype_inventoryitems'
+
+
 class DeviceTypeEditView(generic.ObjectEditView):
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeForm
@@ -890,6 +897,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         'dcim.add_rearporttemplate',
         'dcim.add_modulebaytemplate',
         'dcim.add_devicebaytemplate',
+        'dcim.add_inventoryitemtemplate',
     ]
     queryset = DeviceType.objects.all()
     model_form = forms.DeviceTypeImportForm
@@ -903,6 +911,7 @@ class DeviceTypeImportView(generic.ObjectImportView):
         ('front-ports', forms.FrontPortTemplateImportForm),
         ('module-bays', forms.ModuleBayTemplateImportForm),
         ('device-bays', forms.DeviceBayTemplateImportForm),
+        ('inventory-items', forms.InventoryItemTemplateImportForm),
     ))
 
     def prep_related_object_data(self, parent, data):
@@ -1362,6 +1371,51 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView):
     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
 #

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

@@ -44,6 +44,9 @@
         {% 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>
         {% 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>
     </div>
   {% endif %}
@@ -127,4 +130,12 @@
             </li>
         {% endif %}
     {% 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 %}