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

+ 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.

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

@@ -20,6 +20,7 @@ __all__ = [
     'NestedInterfaceSerializer',
     'NestedInterfaceTemplateSerializer',
     'NestedInventoryItemSerializer',
+    'NestedInventoryItemRoleSerializer',
     'NestedManufacturerSerializer',
     'NestedModuleBaySerializer',
     'NestedModuleBayTemplateSerializer',
@@ -384,6 +385,15 @@ class NestedInventoryItemSerializer(WritableNestedSerializer):
         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
 #

+ 16 - 4
netbox/dcim/api/serializers.py

@@ -806,10 +806,6 @@ class DeviceBaySerializer(PrimaryModelSerializer):
         ]
 
 
-#
-# Inventory items
-#
-
 class InventoryItemSerializer(PrimaryModelSerializer):
     url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
     device = NestedDeviceSerializer()
@@ -825,6 +821,22 @@ class InventoryItemSerializer(PrimaryModelSerializer):
         ]
 
 
+#
+# 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
 #

+ 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('inventory-items', views.InventoryItemViewSet)
 
+# Device component roles
+router.register('inventory-item-roles', views.InventoryItemRoleViewSet)
+
 # Cables
 router.register('cables', views.CableViewSet)
 

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

@@ -623,6 +623,18 @@ class InventoryItemViewSet(ModelViewSet):
     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
 #

+ 9 - 0
netbox/dcim/filtersets.py

@@ -39,6 +39,7 @@ __all__ = (
     'InterfaceFilterSet',
     'InterfaceTemplateFilterSet',
     'InventoryItemFilterSet',
+    'InventoryItemRoleFilterSet',
     'LocationFilterSet',
     'ManufacturerFilterSet',
     'ModuleBayFilterSet',
@@ -1304,6 +1305,14 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
         return queryset.filter(qs_filter)
 
 
+class InventoryItemRoleFilterSet(OrganizationalModelFilterSet):
+    tag = TagFilter()
+
+    class Meta:
+        model = InventoryItemRole
+        fields = ['id', 'name', 'slug', 'color']
+
+
 class VirtualChassisFilterSet(PrimaryModelFilterSet):
     q = django_filters.CharFilter(
         method='search',

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

@@ -30,6 +30,7 @@ __all__ = (
     'InterfaceBulkEditForm',
     'InterfaceTemplateBulkEditForm',
     'InventoryItemBulkEditForm',
+    'InventoryItemRoleBulkEditForm',
     'LocationBulkEditForm',
     'ManufacturerBulkEditForm',
     'ModuleBulkEditForm',
@@ -1186,3 +1187,24 @@ class InventoryItemBulkEditForm(
 
     class Meta:
         nullable_fields = ['label', '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']

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

@@ -24,6 +24,7 @@ __all__ = (
     'FrontPortCSVForm',
     'InterfaceCSVForm',
     'InventoryItemCSVForm',
+    'InventoryItemRoleCSVForm',
     'LocationCSVForm',
     'ManufacturerCSVForm',
     'ModuleCSVForm',
@@ -805,6 +806,25 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm):
             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):
     # Termination A
     side_a_device = CSVModelChoiceField(
@@ -906,6 +926,10 @@ class CableCSVForm(CustomFieldModelCSVForm):
         return length_unit if length_unit is not None else ''
 
 
+#
+# Virtual chassis
+#
+
 class VirtualChassisCSVForm(CustomFieldModelCSVForm):
     master = CSVModelChoiceField(
         queryset=Device.objects.all(),
@@ -919,6 +943,10 @@ class VirtualChassisCSVForm(CustomFieldModelCSVForm):
         fields = ('name', 'domain', 'master')
 
 
+#
+# Power
+#
+
 class PowerPanelCSVForm(CustomFieldModelCSVForm):
     site = CSVModelChoiceField(
         queryset=Site.objects.all(),

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

@@ -27,6 +27,7 @@ __all__ = (
     'InterfaceConnectionFilterForm',
     'InterfaceFilterForm',
     'InventoryItemFilterForm',
+    'InventoryItemRoleFilterForm',
     'LocationFilterForm',
     'ManufacturerFilterForm',
     'ModuleFilterForm',
@@ -1120,6 +1121,15 @@ class InventoryItemFilterForm(DeviceComponentFilterForm):
     tag = TagFilterField(model)
 
 
+#
+# Device component roles
+#
+
+class InventoryItemRoleFilterForm(CustomFieldModelFilterForm):
+    model = InventoryItemRole
+    tag = TagFilterField(model)
+
+
 #
 # Connections
 #

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

@@ -37,6 +37,7 @@ __all__ = (
     'InterfaceForm',
     'InterfaceTemplateForm',
     'InventoryItemForm',
+    'InventoryItemRoleForm',
     'LocationForm',
     'ManufacturerForm',
     'ModuleForm',
@@ -1382,3 +1383,21 @@ class InventoryItemForm(CustomFieldModelForm):
             'device', 'parent', 'name', 'label', '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',
+        ]

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

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

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

@@ -25,6 +25,7 @@ __all__ = (
     'InterfaceType',
     'InterfaceTemplateType',
     'InventoryItemType',
+    'InventoryItemRoleType',
     'LocationType',
     'ManufacturerType',
     'ModuleType',
@@ -242,6 +243,14 @@ class InventoryItemType(ComponentObjectType):
         filterset_class = filtersets.InventoryItemFilterSet
 
 
+class InventoryItemRoleType(OrganizationalObjectType):
+
+    class Meta:
+        model = models.InventoryItemRole
+        fields = '__all__'
+        filterset_class = filtersets.InventoryItemRoleFilterSet
+
+
 class LocationType(VLANGroupsMixin, ImageAttachmentsMixin, OrganizationalObjectType):
 
     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.svg import CableTraceSVG
 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.mptt import TreeManager
 from utilities.ordering import naturalize_interface
@@ -30,6 +31,7 @@ __all__ = (
     'FrontPort',
     'Interface',
     'InventoryItem',
+    'InventoryItemRole',
     'ModuleBay',
     'PathEndpoint',
     'PowerOutlet',
@@ -946,6 +948,38 @@ class DeviceBay(ComponentModel):
 # 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')
 class InventoryItem(MPTTModel, ComponentModel):
     """
@@ -973,6 +1007,13 @@ class InventoryItem(MPTTModel, ComponentModel):
         blank=True,
         help_text='Manufacturer-assigned part identifier'
     )
+    role = models.ForeignKey(
+        to='dcim.InventoryItemRole',
+        on_delete=models.PROTECT,
+        related_name='inventory_items',
+        blank=True,
+        null=True
+    )
     serial = models.CharField(
         max_length=50,
         verbose_name='Serial number',
@@ -993,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel):
 
     objects = TreeManager()
 
-    clone_fields = ['device', 'parent', 'manufacturer', 'part_id']
+    clone_fields = ['device', 'parent', 'manufacturer', 'part_id', 'role']
 
     class Meta:
         ordering = ('device__id', 'parent__id', '_name')

+ 28 - 3
netbox/dcim/tables/devices.py

@@ -2,8 +2,8 @@ import django_tables2 as tables
 from django_tables2.utils import Accessor
 
 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 utilities.tables import (
@@ -33,6 +33,7 @@ __all__ = (
     'DeviceTable',
     'FrontPortTable',
     'InterfaceTable',
+    'InventoryItemRoleTable',
     'InventoryItemTable',
     'ModuleBayTable',
     'PlatformTable',
@@ -68,11 +69,11 @@ def get_interface_state_attribute(record):
     else:
         return "disabled"
 
+
 #
 # Device roles
 #
 
-
 class DeviceRoleTable(BaseTable):
     pk = ToggleColumn()
     name = tables.Column(
@@ -791,6 +792,30 @@ class InventoryItemTable(DeviceComponentTable):
         default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag')
 
 
+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')
+
+
 class DeviceInventoryItemTable(InventoryItemTable):
     name = tables.TemplateColumn(
         template_code='<a href="{{ record.get_absolute_url }}" style="padding-left: {{ record.level }}0px">'

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

@@ -1649,6 +1649,41 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
         ]
 
 
+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):
     model = Cable
     brief_fields = ['display', 'id', 'label', 'url']

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

@@ -3091,6 +3091,33 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
         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):
     queryset = VirtualChassis.objects.all()
     filterset = VirtualChassisFilterSet

+ 36 - 1
netbox/dcim/tests/test_views.py

@@ -1408,7 +1408,7 @@ class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase):
         tags = create_tags('Alpha', 'Bravo', 'Charlie')
 
         cls.form_data = {
-            'name': 'Devie Role X',
+            'name': 'Device Role X',
             'slug': 'device-role-x',
             'color': 'c0c0c0',
             'vm_role': False,
@@ -2375,6 +2375,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
 # Blocked by lack of common creation view for cables (termination A must be initialized)
 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('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
     path('cables/', views.CableListView.as_view(), name='cable_list'),
     path('cables/import/', views.CableBulkImportView.as_view(), name='cable_import'),

+ 53 - 0
netbox/dcim/views.py

@@ -2428,6 +2428,59 @@ class InventoryItemBulkDeleteView(generic.BulkDeleteView):
     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
 #

+ 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', 'devicebay', 'Device Bays', actions=['import']),
                 get_model_item('dcim', 'inventoryitem', 'Inventory Items', actions=['import']),
+                get_model_item('dcim', 'inventoryitemrole', 'Inventory Item Roles'),
             ),
         ),
     ),

+ 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 %}