Procházet zdrojové kódy

Merge pull request #8172 from netbox-community/3087-inventory-item-roles

Closes #3087: Add inventory item roles
Jeremy Stretch před 4 roky
rodič
revize
a58f1c6a7d

+ 1 - 1
docs/models/dcim/inventoryitem.md

@@ -2,6 +2,6 @@
 
 
 Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes.
 Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes.
 
 
-Each inventory item can be assigned a manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox).
+Each inventory item can be assigned a functional role, manufacturer, part ID, serial number, and asset tag (all optional). A boolean toggle is also provided to indicate whether each item was entered manually or discovered automatically (by some process outside of NetBox).
 
 
 Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device.
 Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device.

+ 3 - 0
docs/models/dcim/inventoryitemrole.md

@@ -0,0 +1,3 @@
+# Inventory Item Roles
+
+Inventory items can be organized by functional roles, which are fully customizable by the user. For example, you might create roles for power supplies, fans, interface optics, etc.

+ 8 - 1
docs/release-notes/version-3.2.md

@@ -18,6 +18,10 @@
 
 
 A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
 A new REST API endpoint has been added at `/api/ipam/vlan-groups/<pk>/available-vlans/`. A GET request to this endpoint will return a list of available VLANs within the group. A POST request can be made to this endpoint specifying the name(s) of one or more VLANs to create within the group, and their VLAN IDs will be assigned automatically.
 
 
+#### Inventory Item Roles ([#3087](https://github.com/netbox-community/netbox/issues/3087))
+
+A new model has been introduced to represent function roles for inventory items, similar to device roles. The assignment of roles to inventory items is optional.
+
 #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
 #### Modules & Module Types ([#7844](https://github.com/netbox-community/netbox/issues/7844))
 
 
 Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. 
 Several new models have been added to support field-replaceable device modules, such as those within a chassis-based switch or router. Similar to devices, each module is instantiated from a user-defined module type, and can have components associated with it. These components become available to the parent device once the module has been installed within a module bay. This makes it very convenient to replicate the addition and deletion of device components as modules are installed and removed. 
@@ -55,7 +59,8 @@ FIELD_CHOICES = {
 
 
 ### REST API Changes
 ### REST API Changes
 
 
-* Added the following endpoints for modules & module types:
+* Added the following endpoints:
+    * `/api/dcim/inventory-item-roles/`
     * `/api/dcim/modules/`
     * `/api/dcim/modules/`
     * `/api/dcim/module-bays/`
     * `/api/dcim/module-bays/`
     * `/api/dcim/module-bay-templates/`
     * `/api/dcim/module-bay-templates/`
@@ -70,6 +75,8 @@ FIELD_CHOICES = {
     * Added `module` field
     * Added `module` field
 * dcim.Interface
 * dcim.Interface
     * Added `module` field
     * Added `module` field
+* dcim.InventoryItem
+    * Added `role` field
 * dcim.PowerPort
 * dcim.PowerPort
     * Added `module` field
     * Added `module` field
 * dcim.PowerOutlet
 * dcim.PowerOutlet

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

@@ -20,6 +20,7 @@ __all__ = [
     'NestedInterfaceSerializer',
     'NestedInterfaceSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInventoryItemSerializer',
     'NestedInventoryItemSerializer',
+    'NestedInventoryItemRoleSerializer',
     'NestedManufacturerSerializer',
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
     'NestedModuleBayTemplateSerializer',
@@ -384,6 +385,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
         fields = ['id', 'url', 'display', 'device', 'name', '_depth']
         fields = ['id', 'url', 'display', 'device', 'name', '_depth']
 
 
 
 
+class NestedInventoryItemRoleSerializer(WritableNestedSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
+    inventoryitem_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = models.InventoryItemRole
+        fields = ['id', 'url', 'display', 'name', 'slug', 'inventoryitem_count']
+
+
 #
 #
 # Cables
 # Cables
 #
 #

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

@@ -806,25 +806,38 @@ class DeviceBaySerializer(PrimaryModelSerializer):
         ]
         ]
 
 
 
 
-#
-# Inventory items
-#
-
 class InventoryItemSerializer(PrimaryModelSerializer):
 class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
     device = NestedDeviceSerializer()
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
     manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None)
+    role = NestedInventoryItemRoleSerializer(required=False, allow_null=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
     _depth = serializers.IntegerField(source='level', read_only=True)
 
 
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
-            'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial',
+            'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
             'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
             'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
         ]
         ]
 
 
 
 
+#
+# Device component roles
+#
+
+class InventoryItemRoleSerializer(PrimaryModelSerializer):
+    url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemrole-detail')
+    inventoryitem_count = serializers.IntegerField(read_only=True)
+
+    class Meta:
+        model = InventoryItemRole
+        fields = [
+            'id', 'url', 'display', 'name', 'slug', 'color', 'description', 'tags', 'custom_fields', 'created',
+            'last_updated', 'inventoryitem_count',
+        ]
+
+
 #
 #
 # Cables
 # Cables
 #
 #

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

@@ -50,6 +50,9 @@ router.register('module-bays', views.ModuleBayViewSet)
 router.register('device-bays', views.DeviceBayViewSet)
 router.register('device-bays', views.DeviceBayViewSet)
 router.register('inventory-items', views.InventoryItemViewSet)
 router.register('inventory-items', views.InventoryItemViewSet)
 
 
+# Device component roles
+router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
+
 # Cables
 # Cables
 router.register('cables', views.CableViewSet)
 router.register('cables', views.CableViewSet)
 
 

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

@@ -623,6 +623,18 @@ class InventoryItemViewSet(ModelViewSet):
     brief_prefetch_fields = ['device']
     brief_prefetch_fields = ['device']
 
 
 
 
+#
+# Device component roles
+#
+
+class InventoryItemRoleViewSet(CustomFieldModelViewSet):
+    queryset = InventoryItemRole.objects.prefetch_related('tags').annotate(
+        inventoryitem_count=count_related(InventoryItem, 'role')
+    )
+    serializer_class = serializers.InventoryItemRoleSerializer
+    filterset_class = filtersets.InventoryItemRoleFilterSet
+
+
 #
 #
 # Cables
 # Cables
 #
 #

+ 19 - 0
netbox/dcim/filtersets.py

@@ -39,6 +39,7 @@ __all__ = (
     'InterfaceFilterSet',
     'InterfaceFilterSet',
     'InterfaceTemplateFilterSet',
     'InterfaceTemplateFilterSet',
     'InventoryItemFilterSet',
     'InventoryItemFilterSet',
+    'InventoryItemRoleFilterSet',
     'LocationFilterSet',
     'LocationFilterSet',
     'ManufacturerFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
     'ModuleBayFilterSet',
@@ -1283,6 +1284,16 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         to_field_name='slug',
         to_field_name='slug',
         label='Manufacturer (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)',
+    )
     serial = django_filters.CharFilter(
     serial = django_filters.CharFilter(
         lookup_expr='iexact'
         lookup_expr='iexact'
     )
     )
@@ -1304,6 +1315,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
         return queryset.filter(qs_filter)
 
 
 
 
+class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
+    tag = TagFilter()
+
+    class Meta:
+        model = InventoryItemRole
+        fields = ['id', 'name', 'slug', 'color']
+
+
 class VirtualChassisFilterSet(PrimaryModelFilterSet):
 class VirtualChassisFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
     q = django_filters.CharFilter(
         method='search',
         method='search',

+ 3 - 3
netbox/dcim/forms/bulk_create.py

@@ -107,11 +107,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm):
 
 
 
 
 class InventoryItemBulkCreateForm(
 class InventoryItemBulkCreateForm(
-    form_from_model(InventoryItem, ['manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
+    form_from_model(InventoryItem, ['role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered']),
     DeviceBulkAddComponentForm
     DeviceBulkAddComponentForm
 ):
 ):
     model = InventoryItem
     model = InventoryItem
     field_order = (
     field_order = (
-        'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
-        'tags',
+        'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+        'description', 'tags',
     )
     )

+ 28 - 2
netbox/dcim/forms/bulk_edit.py

@@ -30,6 +30,7 @@ __all__ = (
     'InterfaceBulkEditForm',
     'InterfaceBulkEditForm',
     'InterfaceTemplateBulkEditForm',
     'InterfaceTemplateBulkEditForm',
     'InventoryItemBulkEditForm',
     'InventoryItemBulkEditForm',
+    'InventoryItemRoleBulkEditForm',
     'LocationBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBulkEditForm',
     'ModuleBulkEditForm',
@@ -1171,7 +1172,7 @@ class DeviceBayBulkEditForm(
 
 
 
 
 class InventoryItemBulkEditForm(
 class InventoryItemBulkEditForm(
-    form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']),
+    form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']),
     AddRemoveTagsForm,
     AddRemoveTagsForm,
     CustomFieldModelBulkEditForm
     CustomFieldModelBulkEditForm
 ):
 ):
@@ -1179,10 +1180,35 @@ class InventoryItemBulkEditForm(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         widget=forms.MultipleHiddenInput()
         widget=forms.MultipleHiddenInput()
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False
+    )
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
     )
     )
 
 
     class Meta:
     class Meta:
-        nullable_fields = ['label', 'manufacturer', 'part_id', 'description']
+        nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description']
+
+
+#
+# Device component roles
+#
+
+class InventoryItemRoleBulkEditForm(AddRemoveTagsForm, CustomFieldModelBulkEditForm):
+    pk = forms.ModelMultipleChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        widget=forms.MultipleHiddenInput
+    )
+    color = ColorField(
+        required=False
+    )
+    description = forms.CharField(
+        max_length=200,
+        required=False
+    )
+
+    class Meta:
+        nullable_fields = ['color', 'description']

+ 35 - 1
netbox/dcim/forms/bulk_import.py

@@ -24,6 +24,7 @@ __all__ = (
     'FrontPortCSVForm',
     'FrontPortCSVForm',
     'InterfaceCSVForm',
     'InterfaceCSVForm',
     'InventoryItemCSVForm',
     'InventoryItemCSVForm',
+    'InventoryItemRoleCSVForm',
     'LocationCSVForm',
     'LocationCSVForm',
     'ManufacturerCSVForm',
     'ManufacturerCSVForm',
     'ModuleCSVForm',
     'ModuleCSVForm',
@@ -771,6 +772,11 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
         to_field_name='name'
         to_field_name='name'
     )
     )
+    role = CSVModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        to_field_name='name',
+        required=False
+    )
     manufacturer = CSVModelChoiceField(
     manufacturer = CSVModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         to_field_name='name',
         to_field_name='name',
@@ -786,7 +792,8 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
-            'device', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered', 'description',
+            'device', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'discovered',
+            'description',
         )
         )
 
 
     def __init__(self, *args, **kwargs):
     def __init__(self, *args, **kwargs):
@@ -805,6 +812,25 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
             self.fields['parent'].queryset = InventoryItem.objects.none()
             self.fields['parent'].queryset = InventoryItem.objects.none()
 
 
 
 
+#
+# Device component roles
+#
+
+class InventoryItemRoleCSVForm(CustomFieldModelCSVForm):
+    slug = SlugField()
+
+    class Meta:
+        model = InventoryItemRole
+        fields = ('name', 'slug', 'color', 'description')
+        help_texts = {
+            'color': mark_safe('RGB color in hexadecimal (e.g. <code>00ff00</code>)'),
+        }
+
+
+#
+# Cables
+#
+
 class CableCSVForm(CustomFieldModelCSVForm):
 class CableCSVForm(CustomFieldModelCSVForm):
     # Termination A
     # Termination A
     side_a_device = CSVModelChoiceField(
     side_a_device = CSVModelChoiceField(
@@ -906,6 +932,10 @@ class CableCSVForm(CustomFieldModelCSVForm):
         return length_unit if length_unit is not None else ''
         return length_unit if length_unit is not None else ''
 
 
 
 
+#
+# Virtual chassis
+#
+
 class VirtualChassisCSVForm(CustomFieldModelCSVForm):
 class VirtualChassisCSVForm(CustomFieldModelCSVForm):
     master = CSVModelChoiceField(
     master = CSVModelChoiceField(
         queryset=Device.objects.all(),
         queryset=Device.objects.all(),
@@ -919,6 +949,10 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'domain', 'master')
         fields = ('name', 'domain', 'master')
 
 
 
 
+#
+# Power
+#
+
 class PowerPanelCSVForm(CustomFieldModelCSVForm):
 class PowerPanelCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),
         queryset=Site.objects.all(),

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

@@ -27,6 +27,7 @@ __all__ = (
     'InterfaceConnectionFilterForm',
     'InterfaceConnectionFilterForm',
     'InterfaceFilterForm',
     'InterfaceFilterForm',
     'InventoryItemFilterForm',
     'InventoryItemFilterForm',
+    'InventoryItemRoleFilterForm',
     'LocationFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
     'ManufacturerFilterForm',
     'ModuleFilterForm',
     'ModuleFilterForm',
@@ -1099,6 +1100,12 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
         ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
         ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'],
         ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
         ['region_id', 'site_group_id', 'site_id', 'location_id', 'virtual_chassis_id', 'device_id'],
     ]
     ]
+    role_id = DynamicModelMultipleChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False,
+        label=_('Role'),
+        fetch_trigger='open'
+    )
     manufacturer_id = DynamicModelMultipleChoiceField(
     manufacturer_id = DynamicModelMultipleChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False,
         required=False,
@@ -1120,6 +1127,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
     tag = TagFilterField(model)
 
 
 
 
+#
+# Device component roles
+#
+
+class InventoryItemRoleFilterForm(CustomFieldModelFilterForm):
+    model = InventoryItemRole
+    tag = TagFilterField(model)
+
+
 #
 #
 # Connections
 # Connections
 #
 #

+ 25 - 2
netbox/dcim/forms/models.py

@@ -37,6 +37,7 @@ __all__ = (
     'InterfaceForm',
     'InterfaceForm',
     'InterfaceTemplateForm',
     'InterfaceTemplateForm',
     'InventoryItemForm',
     'InventoryItemForm',
+    'InventoryItemRoleForm',
     'LocationForm',
     'LocationForm',
     'ManufacturerForm',
     'ManufacturerForm',
     'ModuleForm',
     'ModuleForm',
@@ -1367,6 +1368,10 @@ class InventoryItemForm(CustomFieldModelForm):
             'device_id': '$device'
             'device_id': '$device'
         }
         }
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False
+    )
     manufacturer = DynamicModelChoiceField(
     manufacturer = DynamicModelChoiceField(
         queryset=Manufacturer.objects.all(),
         queryset=Manufacturer.objects.all(),
         required=False
         required=False
@@ -1379,6 +1384,24 @@ class InventoryItemForm(CustomFieldModelForm):
     class Meta:
     class Meta:
         model = InventoryItem
         model = InventoryItem
         fields = [
         fields = [
-            'device', 'parent', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'tags',
+            'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+            'description', 'tags',
+        ]
+
+
+#
+# Device component roles
+#
+
+class InventoryItemRoleForm(CustomFieldModelForm):
+    slug = SlugField()
+    tags = DynamicModelMultipleChoiceField(
+        queryset=Tag.objects.all(),
+        required=False
+    )
+
+    class Meta:
+        model = InventoryItemRole
+        fields = [
+            'name', 'slug', 'color', 'description', 'tags',
         ]
         ]

+ 9 - 5
netbox/dcim/forms/object_create.py

@@ -652,10 +652,6 @@ class DeviceBayCreateForm(ComponentCreateForm):
 
 
 class InventoryItemCreateForm(ComponentCreateForm):
 class InventoryItemCreateForm(ComponentCreateForm):
     model = InventoryItem
     model = InventoryItem
-    manufacturer = DynamicModelChoiceField(
-        queryset=Manufacturer.objects.all(),
-        required=False
-    )
     parent = DynamicModelChoiceField(
     parent = DynamicModelChoiceField(
         queryset=InventoryItem.objects.all(),
         queryset=InventoryItem.objects.all(),
         required=False,
         required=False,
@@ -663,6 +659,14 @@ class InventoryItemCreateForm(ComponentCreateForm):
             'device_id': '$device'
             'device_id': '$device'
         }
         }
     )
     )
+    role = DynamicModelChoiceField(
+        queryset=InventoryItemRole.objects.all(),
+        required=False
+    )
+    manufacturer = DynamicModelChoiceField(
+        queryset=Manufacturer.objects.all(),
+        required=False
+    )
     part_id = forms.CharField(
     part_id = forms.CharField(
         max_length=50,
         max_length=50,
         required=False,
         required=False,
@@ -677,6 +681,6 @@ class InventoryItemCreateForm(ComponentCreateForm):
         required=False,
         required=False,
     )
     )
     field_order = (
     field_order = (
-        'device', 'parent', 'name_pattern', 'label_pattern', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+        'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
         'description', 'tags',
         'description', 'tags',
     )
     )

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

@@ -50,6 +50,9 @@ class DCIMQuery(graphene.ObjectType):
     inventory_item = ObjectField(InventoryItemType)
     inventory_item = ObjectField(InventoryItemType)
     inventory_item_list = ObjectListField(InventoryItemType)
     inventory_item_list = ObjectListField(InventoryItemType)
 
 
+    inventory_item_role = ObjectField(InventoryItemRoleType)
+    inventory_item_role_list = ObjectListField(InventoryItemRoleType)
+
     location = ObjectField(LocationType)
     location = ObjectField(LocationType)
     location_list = ObjectListField(LocationType)
     location_list = ObjectListField(LocationType)
 
 

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

@@ -25,6 +25,7 @@ __all__ = (
     'InterfaceType',
     'InterfaceType',
     'InterfaceTemplateType',
     'InterfaceTemplateType',
     'InventoryItemType',
     'InventoryItemType',
+    'InventoryItemRoleType',
     'LocationType',
     'LocationType',
     'ManufacturerType',
     'ManufacturerType',
     'ModuleType',
     'ModuleType',
@@ -242,6 +243,14 @@ class InventoryItemType(ComponentObjectType):
         filterset_class = filtersets.InventoryItemFilterSet
         filterset_class = filtersets.InventoryItemFilterSet
 
 
 
 
+class InventoryItemRoleType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.InventoryItemRole
+        fields = '__all__'
+        filterset_class = filtersets.InventoryItemRoleFilterSet
+
+
 class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
 class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
 
 
     class Meta:
     class Meta:

+ 38 - 0
netbox/dcim/migrations/0146_inventoryitemrole.py

@@ -0,0 +1,38 @@
+import django.core.serializers.json
+from django.db import migrations, models
+import django.db.models.deletion
+import taggit.managers
+import utilities.fields
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('extras', '0067_configcontext_cluster_types'),
+        ('dcim', '0145_modules'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='InventoryItemRole',
+            fields=[
+                ('created', models.DateField(auto_now_add=True, null=True)),
+                ('last_updated', models.DateTimeField(auto_now=True, null=True)),
+                ('custom_field_data', models.JSONField(blank=True, default=dict, encoder=django.core.serializers.json.DjangoJSONEncoder)),
+                ('id', models.BigAutoField(primary_key=True, serialize=False)),
+                ('name', models.CharField(max_length=100, unique=True)),
+                ('slug', models.SlugField(max_length=100, unique=True)),
+                ('color', utilities.fields.ColorField(default='9e9e9e', max_length=6)),
+                ('description', models.CharField(blank=True, max_length=200)),
+                ('tags', taggit.managers.TaggableManager(through='extras.TaggedItem', to='extras.Tag')),
+            ],
+            options={
+                'ordering': ['name'],
+            },
+        ),
+        migrations.AddField(
+            model_name='inventoryitem',
+            name='role',
+            field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_items', to='dcim.inventoryitemrole'),
+        ),
+    ]

+ 43 - 2
netbox/dcim/models/device_components.py

@@ -12,7 +12,8 @@ from dcim.constants import *
 from dcim.fields import MACAddressField, WWNField
 from dcim.fields import MACAddressField, WWNField
 from dcim.svg import CableTraceSVG
 from dcim.svg import CableTraceSVG
 from extras.utils import extras_features
 from extras.utils import extras_features
-from netbox.models import PrimaryModel
+from netbox.models import OrganizationalModel, PrimaryModel
+from utilities.choices import ColorChoices
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.fields import ColorField, NaturalOrderingField
 from utilities.mptt import TreeManager
 from utilities.mptt import TreeManager
 from utilities.ordering import naturalize_interface
 from utilities.ordering import naturalize_interface
@@ -30,6 +31,7 @@ __all__ = (
     'FrontPort',
     'FrontPort',
     'Interface',
     'Interface',
     'InventoryItem',
     'InventoryItem',
+    'InventoryItemRole',
     'ModuleBay',
     'ModuleBay',
     'PathEndpoint',
     'PathEndpoint',
     'PowerOutlet',
     'PowerOutlet',
@@ -946,6 +948,38 @@ class DeviceBay(ComponentModel):
 # Inventory items
 # Inventory items
 #
 #
 
 
+
+@extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
+class InventoryItemRole(OrganizationalModel):
+    """
+    Inventory items may optionally be assigned a functional role.
+    """
+    name = models.CharField(
+        max_length=100,
+        unique=True
+    )
+    slug = models.SlugField(
+        max_length=100,
+        unique=True
+    )
+    color = ColorField(
+        default=ColorChoices.COLOR_GREY
+    )
+    description = models.CharField(
+        max_length=200,
+        blank=True,
+    )
+
+    class Meta:
+        ordering = ['name']
+
+    def __str__(self):
+        return self.name
+
+    def get_absolute_url(self):
+        return reverse('dcim:inventoryitemrole', args=[self.pk])
+
+
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 @extras_features('custom_fields', 'custom_links', 'export_templates', 'tags', 'webhooks')
 class InventoryItem(MPTTModel, ComponentModel):
 class InventoryItem(MPTTModel, ComponentModel):
     """
     """
@@ -960,6 +994,13 @@ class InventoryItem(MPTTModel, ComponentModel):
         null=True,
         null=True,
         db_index=True
         db_index=True
     )
     )
+    role = models.ForeignKey(
+        to='dcim.InventoryItemRole',
+        on_delete=models.PROTECT,
+        related_name='inventory_items',
+        blank=True,
+        null=True
+    )
     manufacturer = models.ForeignKey(
     manufacturer = models.ForeignKey(
         to='dcim.Manufacturer',
         to='dcim.Manufacturer',
         on_delete=models.PROTECT,
         on_delete=models.PROTECT,
@@ -993,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel):
 
 
     objects = TreeManager()
     objects = TreeManager()
 
 
-    clone_fields = ['device', 'parent', 'manufacturer', 'part_id']
+    clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id']
 
 
     class Meta:
     class Meta:
         ordering = ('device__id', 'parent__id', '_name')
         ordering = ('device__id', 'parent__id', '_name')

+ 37 - 10
netbox/dcim/tables/devices.py

@@ -2,8 +2,8 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 from django_tables2.utils import Accessor
 
 
 from dcim.models import (
 from dcim.models import (
-    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem, ModuleBay,
-    Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
+    ConsolePort, ConsoleServerPort, Device, DeviceBay, DeviceRole, FrontPort, Interface, InventoryItem,
+    InventoryItemRole, ModuleBay, Platform, PowerOutlet, PowerPort, RearPort, VirtualChassis,
 )
 )
 from tenancy.tables import TenantColumn
 from tenancy.tables import TenantColumn
 from utilities.tables import (
 from utilities.tables import (
@@ -33,6 +33,7 @@ __all__ = (
     'DeviceTable',
     'DeviceTable',
     'FrontPortTable',
     'FrontPortTable',
     'InterfaceTable',
     'InterfaceTable',
+    'InventoryItemRoleTable',
     'InventoryItemTable',
     'InventoryItemTable',
     'ModuleBayTable',
     'ModuleBayTable',
     'PlatformTable',
     'PlatformTable',
@@ -68,11 +69,11 @@ def get_interface_state_attribute(record):
     else:
     else:
         return "disabled"
         return "disabled"
 
 
+
 #
 #
 # Device roles
 # Device roles
 #
 #
 
 
-
 class DeviceRoleTable(BaseTable):
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     pk = ToggleColumn()
     name = tables.Column(
     name = tables.Column(
@@ -773,6 +774,9 @@ class InventoryItemTable(DeviceComponentTable):
             'args': [Accessor('device_id')],
             'args': [Accessor('device_id')],
         }
         }
     )
     )
+    role = tables.Column(
+        linkify=True
+    )
     manufacturer = tables.Column(
     manufacturer = tables.Column(
         linkify=True
         linkify=True
     )
     )
@@ -785,10 +789,10 @@ class InventoryItemTable(DeviceComponentTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
-            'pk', 'id', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
-            'discovered', 'tags',
+            'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
+            'description', 'discovered', 'tags',
         )
         )
-        default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
+        default_columns = ('pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 
 
 
 
 class DeviceInventoryItemTable(InventoryItemTable):
 class DeviceInventoryItemTable(InventoryItemTable):
@@ -806,13 +810,36 @@ class DeviceInventoryItemTable(InventoryItemTable):
     class Meta(BaseTable.Meta):
     class Meta(BaseTable.Meta):
         model = InventoryItem
         model = InventoryItem
         fields = (
         fields = (
-            'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
-            'tags', 'actions',
+            'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
+            'discovered', 'tags', 'actions',
         )
         )
         default_columns = (
         default_columns = (
-            'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered',
-            'actions',
+            'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions',
+        )
+
+
+class InventoryItemRoleTable(BaseTable):
+    pk = ToggleColumn()
+    name = tables.Column(
+        linkify=True
+    )
+    inventoryitem_count = LinkedCountColumn(
+        viewname='dcim:inventoryitem_list',
+        url_params={'role_id': 'pk'},
+        verbose_name='Items'
+    )
+    color = ColorColumn()
+    tags = TagColumn(
+        url_name='dcim:inventoryitemrole_list'
+    )
+    actions = ButtonsColumn(InventoryItemRole)
+
+    class Meta(BaseTable.Meta):
+        model = InventoryItemRole
+        fields = (
+            'pk', 'id', 'name', 'inventoryitem_count', 'color', 'description', 'slug', 'tags', 'actions',
         )
         )
+        default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions')
 
 
 
 
 #
 #

+ 47 - 3
netbox/dcim/tests/test_api.py

@@ -1626,29 +1626,73 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
         devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         devicerole = DeviceRole.objects.create(name='Test Device Role 1', slug='test-device-role-1', color='ff0000')
         device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
         device = Device.objects.create(device_type=devicetype, device_role=devicerole, name='Device 1', site=site)
 
 
-        InventoryItem.objects.create(device=device, name='Inventory Item 1', manufacturer=manufacturer)
-        InventoryItem.objects.create(device=device, name='Inventory Item 2', manufacturer=manufacturer)
-        InventoryItem.objects.create(device=device, name='Inventory Item 3', manufacturer=manufacturer)
+        roles = (
+            InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
+            InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
+        )
+        InventoryItemRole.objects.bulk_create(roles)
+
+        InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
+        InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
+        InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
 
 
         cls.create_data = [
         cls.create_data = [
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Inventory Item 4',
                 'name': 'Inventory Item 4',
+                'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
                 'manufacturer': manufacturer.pk,
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Inventory Item 5',
                 'name': 'Inventory Item 5',
+                'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
                 'manufacturer': manufacturer.pk,
             },
             },
             {
             {
                 'device': device.pk,
                 'device': device.pk,
                 'name': 'Inventory Item 6',
                 'name': 'Inventory Item 6',
+                'role': roles[1].pk,
                 'manufacturer': manufacturer.pk,
                 'manufacturer': manufacturer.pk,
             },
             },
         ]
         ]
 
 
 
 
+class InventoryItemRoleTest(APIViewTestCases.APIViewTestCase):
+    model = InventoryItemRole
+    brief_fields = ['display', 'id', 'inventoryitem_count', 'name', 'slug', 'url']
+    create_data = [
+        {
+            'name': 'Inventory Item Role 4',
+            'slug': 'inventory-item-role-4',
+            'color': 'ffff00',
+        },
+        {
+            'name': 'Inventory Item Role 5',
+            'slug': 'inventory-item-role-5',
+            'color': 'ffff00',
+        },
+        {
+            'name': 'Inventory Item Role 6',
+            'slug': 'inventory-item-role-6',
+            'color': 'ffff00',
+        },
+    ]
+    bulk_update_data = {
+        'description': 'New description',
+    }
+
+    @classmethod
+    def setUpTestData(cls):
+
+        roles = (
+            InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'),
+            InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'),
+            InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'),
+        )
+        InventoryItemRole.objects.bulk_create(roles)
+
+
 class CableTest(APIViewTestCases.APIViewTestCase):
 class CableTest(APIViewTestCases.APIViewTestCase):
     model = Cable
     model = Cable
     brief_fields = ['display', 'id', 'label', 'url']
     brief_fields = ['display', 'id', 'label', 'url']

+ 44 - 4
netbox/dcim/tests/test_filtersets.py

@@ -2949,7 +2949,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
 
 
     @classmethod
     @classmethod
     def setUpTestData(cls):
     def setUpTestData(cls):
-
         manufacturers = (
         manufacturers = (
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
             Manufacturer(name='Manufacturer 1', slug='manufacturer-1'),
             Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
             Manufacturer(name='Manufacturer 2', slug='manufacturer-2'),
@@ -2998,10 +2997,17 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         )
         )
         Device.objects.bulk_create(devices)
         Device.objects.bulk_create(devices)
 
 
+        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(roles)
+
         inventory_items = (
         inventory_items = (
-            InventoryItem(device=devices[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
-            InventoryItem(device=devices[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
-            InventoryItem(device=devices[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
+            InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
+            InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
+            InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
         )
         )
         for i in inventory_items:
         for i in inventory_items:
             i.save()
             i.save()
@@ -3077,6 +3083,13 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         params = {'parent_id': [parent_items[0].pk, parent_items[1].pk]}
         params = {'parent_id': [parent_items[0].pk, parent_items[1].pk]}
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
         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):
     def test_manufacturer(self):
         manufacturers = Manufacturer.objects.all()[:2]
         manufacturers = Manufacturer.objects.all()[:2]
         params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
         params = {'manufacturer_id': [manufacturers[0].pk, manufacturers[1].pk]}
@@ -3091,6 +3104,33 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
         self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
 
 
 
 
+class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
+    queryset = InventoryItemRole.objects.all()
+    filterset = InventoryItemRoleFilterSet
+
+    @classmethod
+    def setUpTestData(cls):
+
+        roles = (
+            InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1', color='ff0000'),
+            InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2', color='00ff00'),
+            InventoryItemRole(name='Inventory Item Role 3', slug='inventory-item-role-3', color='0000ff'),
+        )
+        InventoryItemRole.objects.bulk_create(roles)
+
+    def test_name(self):
+        params = {'name': ['Inventory Item Role 1', 'Inventory Item Role 2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_slug(self):
+        params = {'slug': ['inventory-item-role-1', 'inventory-item-role-2']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+    def test_color(self):
+        params = {'color': ['ff0000', '00ff00']}
+        self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)
+
+
 class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
 class VirtualChassisTestCase(TestCase, ChangeLoggedFilterSetTests):
     queryset = VirtualChassis.objects.all()
     queryset = VirtualChassis.objects.all()
     filterset = VirtualChassisFilterSet
     filterset = VirtualChassisFilterSet

+ 48 - 4
netbox/dcim/tests/test_views.py

@@ -1408,7 +1408,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
-            'name': 'Devie Role X',
+            'name': 'Device Role X',
             'slug': 'device-role-x',
             'slug': 'device-role-x',
             'color': 'c0c0c0',
             'color': 'c0c0c0',
             'vm_role': False,
             'vm_role': False,
@@ -2331,14 +2331,21 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
         device = create_test_device('Device 1')
         device = create_test_device('Device 1')
         manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
         manufacturer, _ = Manufacturer.objects.get_or_create(name='Manufacturer 1', slug='manufacturer-1')
 
 
-        InventoryItem.objects.create(device=device, name='Inventory Item 1')
-        InventoryItem.objects.create(device=device, name='Inventory Item 2')
-        InventoryItem.objects.create(device=device, name='Inventory Item 3')
+        roles = (
+            InventoryItemRole(name='Inventory Item Role 1', slug='inventory-item-role-1'),
+            InventoryItemRole(name='Inventory Item Role 2', slug='inventory-item-role-2'),
+        )
+        InventoryItemRole.objects.bulk_create(roles)
+
+        InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
+        InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
+        InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
 
 
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
 
         cls.form_data = {
         cls.form_data = {
             'device': device.pk,
             'device': device.pk,
+            'role': roles[1].pk,
             'manufacturer': manufacturer.pk,
             'manufacturer': manufacturer.pk,
             'name': 'Inventory Item X',
             'name': 'Inventory Item X',
             'parent': None,
             'parent': None,
@@ -2353,6 +2360,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
         cls.bulk_create_data = {
         cls.bulk_create_data = {
             'device': device.pk,
             'device': device.pk,
             'name_pattern': 'Inventory Item [4-6]',
             'name_pattern': 'Inventory Item [4-6]',
+            'role': roles[1].pk,
             'manufacturer': manufacturer.pk,
             'manufacturer': manufacturer.pk,
             'parent': None,
             'parent': None,
             'discovered': False,
             'discovered': False,
@@ -2363,6 +2371,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
         }
         }
 
 
         cls.bulk_edit_data = {
         cls.bulk_edit_data = {
+            'role': roles[1].pk,
             'part_id': '123456',
             'part_id': '123456',
             'description': 'New description',
             'description': 'New description',
         }
         }
@@ -2375,6 +2384,41 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase):
         )
         )
 
 
 
 
+class InventoryItemRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
+    model = InventoryItemRole
+
+    @classmethod
+    def setUpTestData(cls):
+
+        InventoryItemRole.objects.bulk_create([
+            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'),
+        ])
+
+        tags = create_tags('Alpha', 'Bravo', 'Charlie')
+
+        cls.form_data = {
+            'name': 'Inventory Item Role X',
+            'slug': 'inventory-item-role-x',
+            'color': 'c0c0c0',
+            'description': 'New inventory item role',
+            'tags': [t.pk for t in tags],
+        }
+
+        cls.csv_data = (
+            "name,slug,color",
+            "Inventory Item Role 4,inventory-item-role-4,ff0000",
+            "Inventory Item Role 5,inventory-item-role-5,00ff00",
+            "Inventory Item Role 6,inventory-item-role-6,0000ff",
+        )
+
+        cls.bulk_edit_data = {
+            'color': '00ff00',
+            'description': 'New description',
+        }
+
+
 # TODO: Change base class to PrimaryObjectViewTestCase
 # TODO: Change base class to PrimaryObjectViewTestCase
 # Blocked by lack of common creation view for cables (termination A must be initialized)
 # Blocked by lack of common creation view for cables (termination A must be initialized)
 class CableTestCase(
 class CableTestCase(

+ 11 - 0
netbox/dcim/urls.py

@@ -425,6 +425,17 @@ urlpatterns = [
     path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
     path('inventory-items/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitem_changelog', kwargs={'model': InventoryItem}),
     path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
     path('devices/inventory-items/add/', views.DeviceBulkAddInventoryItemView.as_view(), name='device_bulk_add_inventoryitem'),
 
 
+    # Device roles
+    path('inventory-item-roles/', views.InventoryItemRoleListView.as_view(), name='inventoryitemrole_list'),
+    path('inventory-item-roles/add/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_add'),
+    path('inventory-item-roles/import/', views.InventoryItemRoleBulkImportView.as_view(), name='inventoryitemrole_import'),
+    path('inventory-item-roles/edit/', views.InventoryItemRoleBulkEditView.as_view(), name='inventoryitemrole_bulk_edit'),
+    path('inventory-item-roles/delete/', views.InventoryItemRoleBulkDeleteView.as_view(), name='inventoryitemrole_bulk_delete'),
+    path('inventory-item-roles/<int:pk>/', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'),
+    path('inventory-item-roles/<int:pk>/edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'),
+    path('inventory-item-roles/<int:pk>/delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'),
+    path('inventory-item-roles/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='inventoryitemrole_changelog', kwargs={'model': InventoryItemRole}),
+
     # Cables
     # Cables
     path('cables/', views.CableListView.as_view(), name='cable_list'),
     path('cables/', views.CableListView.as_view(), name='cable_list'),
     path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),
     path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),

+ 55 - 2
netbox/dcim/views.py

@@ -2412,7 +2412,7 @@ class InventoryItemBulkImportView(generic.BulkImportView):
 
 
 
 
 class InventoryItemBulkEditView(generic.BulkEditView):
 class InventoryItemBulkEditView(generic.BulkEditView):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
     filterset = filtersets.InventoryItemFilterSet
     filterset = filtersets.InventoryItemFilterSet
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     form = forms.InventoryItemBulkEditForm
     form = forms.InventoryItemBulkEditForm
@@ -2423,11 +2423,64 @@ class InventoryItemBulkRenameView(generic.BulkRenameView):
 
 
 
 
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
 class InventoryItemBulkDeleteView(generic.BulkDeleteView):
-    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer')
+    queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role')
     table = tables.InventoryItemTable
     table = tables.InventoryItemTable
     template_name = 'dcim/inventoryitem_bulk_delete.html'
     template_name = 'dcim/inventoryitem_bulk_delete.html'
 
 
 
 
+#
+# Inventory item roles
+#
+
+class InventoryItemRoleListView(generic.ObjectListView):
+    queryset = InventoryItemRole.objects.annotate(
+        inventoryitem_count=count_related(InventoryItem, 'role'),
+    )
+    filterset = filtersets.InventoryItemRoleFilterSet
+    filterset_form = forms.InventoryItemRoleFilterForm
+    table = tables.InventoryItemRoleTable
+
+
+class InventoryItemRoleView(generic.ObjectView):
+    queryset = InventoryItemRole.objects.all()
+
+    def get_extra_context(self, request, instance):
+        return {
+            'inventoryitem_count': InventoryItem.objects.filter(role=instance).count(),
+        }
+
+
+class InventoryItemRoleEditView(generic.ObjectEditView):
+    queryset = InventoryItemRole.objects.all()
+    model_form = forms.InventoryItemRoleForm
+
+
+class InventoryItemRoleDeleteView(generic.ObjectDeleteView):
+    queryset = InventoryItemRole.objects.all()
+
+
+class InventoryItemRoleBulkImportView(generic.BulkImportView):
+    queryset = InventoryItemRole.objects.all()
+    model_form = forms.InventoryItemRoleCSVForm
+    table = tables.InventoryItemRoleTable
+
+
+class InventoryItemRoleBulkEditView(generic.BulkEditView):
+    queryset = InventoryItemRole.objects.annotate(
+        inventoryitem_count=count_related(InventoryItem, 'role'),
+    )
+    filterset = filtersets.InventoryItemRoleFilterSet
+    table = tables.InventoryItemRoleTable
+    form = forms.InventoryItemRoleBulkEditForm
+
+
+class InventoryItemRoleBulkDeleteView(generic.BulkDeleteView):
+    queryset = InventoryItemRole.objects.annotate(
+        inventoryitem_count=count_related(InventoryItem, 'role'),
+    )
+    table = tables.InventoryItemRoleTable
+
+
 #
 #
 # Bulk Device component creation
 # Bulk Device component creation
 #
 #

+ 1 - 0
netbox/netbox/navigation_menu.py

@@ -166,6 +166,7 @@ DEVICES_MENU = Menu(
                 get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']),
                 get_model_item('dcim', 'modulebay', 'Module Bays', actions=['import']),
                 get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']),
                 get_model_item('dcim', 'devicebay', 'Device Bays', actions=['import']),
                 get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
                 get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
+                get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'),
             ),
             ),
         ),
         ),
     ),
     ),

+ 11 - 3
netbox/templates/dcim/inventoryitem.html

@@ -13,9 +13,7 @@
     <div class="row mb-3">
     <div class="row mb-3">
         <div class="col col-md-6">
         <div class="col col-md-6">
             <div class="card">
             <div class="card">
-                <h5 class="card-header">
-                    Inventory Item
-                </h5>
+                <h5 class="card-header">Inventory Item</h5>
                 <div class="card-body">
                 <div class="card-body">
                     <table class="table table-hover attr-table">
                     <table class="table table-hover attr-table">
                         <tr>
                         <tr>
@@ -42,6 +40,16 @@
                             <th scope="row">Label</th>
                             <th scope="row">Label</th>
                             <td>{{ object.label|placeholder }}</td>
                             <td>{{ object.label|placeholder }}</td>
                         </tr>
                         </tr>
+                        <tr>
+                            <th scope="row">Role</th>
+                            <td>
+                                {% if object.role %}
+                                    <a href="{{ object.role.get_absolute_url }}">{{ object.role }}</a>
+                                {% else %}
+                                    <span class="text-muted">&mdash;</span>
+                                {% endif %}
+                            </td>
+                        </tr>
                         <tr>
                         <tr>
                             <th scope="row">Manufacturer</th>
                             <th scope="row">Manufacturer</th>
                             <td>
                             <td>

+ 53 - 0
netbox/templates/dcim/inventoryitemrole.html

@@ -0,0 +1,53 @@
+{% extends 'generic/object.html' %}
+{% load helpers %}
+{% load plugins %}
+{% load render_table from django_tables2 %}
+
+{% block breadcrumbs %}
+  <li class="breadcrumb-item"><a href="{% url 'dcim:inventoryitemrole_list' %}">Inventory Item Roles</a></li>
+{% endblock %}
+
+{% block content %}
+<div class="row mb-3">
+	<div class="col col-md-6">
+    <div class="card">
+      <h5 class="card-header">Inventory Item Role</h5>
+      <div class="card-body">
+        <table class="table table-hover attr-table">
+          <tr>
+            <th scope="row">Name</th>
+            <td>{{ object.name }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Description</th>
+            <td>{{ object.description|placeholder }}</td>
+          </tr>
+          <tr>
+            <th scope="row">Color</th>
+            <td>
+              <span class="badge color-label" style="background-color: #{{ object.color }}">&nbsp;</span>
+            </td>
+          </tr>
+          <tr>
+            <th scope="row">Inventory Items</th>
+            <td>
+              <a href="{% url 'dcim:inventoryitem_list' %}?role_id={{ object.pk }}">{{ inventoryitem_count }}</a>
+            </td>
+          </tr>
+        </table>
+      </div>
+    </div>
+    {% include 'inc/panels/tags.html' %}
+    {% plugin_left_page object %}
+	</div>
+	<div class="col col-md-6">
+    {% include 'inc/panels/custom_fields.html' %}
+    {% plugin_right_page object %}
+  </div>
+</div>
+<div class="row mb-3">
+	<div class="col col-md-12">
+    {% plugin_full_width_page object %}
+  </div>
+</div>
+{% endblock %}