From 04fb5e544d2d23622f82cc2d837182eabb123eea Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:18:39 -0500 Subject: [PATCH 1/3] #3087: Add InvetoryItemRole --- docs/models/dcim/inventoryitemrole.md | 3 ++ netbox/dcim/api/nested_serializers.py | 10 ++++ netbox/dcim/api/serializers.py | 20 +++++-- netbox/dcim/api/urls.py | 3 ++ netbox/dcim/api/views.py | 12 +++++ netbox/dcim/filtersets.py | 9 ++++ netbox/dcim/forms/bulk_edit.py | 22 ++++++++ netbox/dcim/forms/bulk_import.py | 28 ++++++++++ netbox/dcim/forms/filtersets.py | 10 ++++ netbox/dcim/forms/models.py | 19 +++++++ netbox/dcim/graphql/schema.py | 3 ++ netbox/dcim/graphql/types.py | 9 ++++ .../dcim/migrations/0146_inventoryitemrole.py | 38 +++++++++++++ netbox/dcim/models/device_components.py | 45 +++++++++++++++- netbox/dcim/tables/devices.py | 31 +++++++++-- netbox/dcim/tests/test_api.py | 35 ++++++++++++ netbox/dcim/tests/test_filtersets.py | 27 ++++++++++ netbox/dcim/tests/test_views.py | 37 ++++++++++++- netbox/dcim/urls.py | 11 ++++ netbox/dcim/views.py | 53 +++++++++++++++++++ netbox/netbox/navigation_menu.py | 1 + netbox/templates/dcim/inventoryitemrole.html | 53 +++++++++++++++++++ 22 files changed, 469 insertions(+), 10 deletions(-) create mode 100644 docs/models/dcim/inventoryitemrole.md create mode 100644 netbox/dcim/migrations/0146_inventoryitemrole.py create mode 100644 netbox/templates/dcim/inventoryitemrole.html diff --git a/docs/models/dcim/inventoryitemrole.md b/docs/models/dcim/inventoryitemrole.md new file mode 100644 index 00000000000..8ed31481ad2 --- /dev/null +++ b/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. diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 9440e5d4b13..0cd112a1d85 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/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 # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index cf6c89333ab..fe84874111a 100644 --- a/netbox/dcim/api/serializers.py +++ b/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 # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index 71a768fd573..be963d36dfb 100644 --- a/netbox/dcim/api/urls.py +++ b/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) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 8838eda2ca3..479abf7b2e6 100644 --- a/netbox/dcim/api/views.py +++ b/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 # diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index d91a9b574c8..5f4840fdee9 100644 --- a/netbox/dcim/filtersets.py +++ b/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', diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index d40ac6fca1f..8fc8835cbc0 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/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'] diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 23d589abf2b..40838c60ca1 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/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. 00ff00)'), + } + + +# +# 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(), diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 819cb91cc93..ae58e1d2f15 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/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 # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index 2d32093c444..b5b15e731b8 100644 --- a/netbox/dcim/forms/models.py +++ b/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', + ] diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 7f660b1922c..8e03ab409ab 100644 --- a/netbox/dcim/graphql/schema.py +++ b/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) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index 51e1960761f..b2a94c3ed47 100644 --- a/netbox/dcim/graphql/types.py +++ b/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: diff --git a/netbox/dcim/migrations/0146_inventoryitemrole.py b/netbox/dcim/migrations/0146_inventoryitemrole.py new file mode 100644 index 00000000000..97de677f8bc --- /dev/null +++ b/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'), + ), + ] diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index ccfe538d707..5329c9e0120 100644 --- a/netbox/dcim/models/device_components.py +++ b/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') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 7805e60c15b..f889d52ec37 100644 --- a/netbox/dcim/tables/devices.py +++ b/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='' diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 3b6410c8c86..a6c7760f4e8 100644 --- a/netbox/dcim/tests/test_api.py +++ b/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'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 8f04fb4d99e..f93e9164d22 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/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 diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 12216a8ac3d..3ac7b9c7211 100644 --- a/netbox/dcim/tests/test_views.py +++ b/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( diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index 8ec30c0ccb1..d45ce75770e 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -425,6 +425,17 @@ urlpatterns = [ path('inventory-items//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//', views.InventoryItemRoleView.as_view(), name='inventoryitemrole'), + path('inventory-item-roles//edit/', views.InventoryItemRoleEditView.as_view(), name='inventoryitemrole_edit'), + path('inventory-item-roles//delete/', views.InventoryItemRoleDeleteView.as_view(), name='inventoryitemrole_delete'), + path('inventory-item-roles//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'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 5aff57a4e7f..8e3d35b3e6b 100644 --- a/netbox/dcim/views.py +++ b/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 # diff --git a/netbox/netbox/navigation_menu.py b/netbox/netbox/navigation_menu.py index 52359dcc671..3b507627344 100644 --- a/netbox/netbox/navigation_menu.py +++ b/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'), ), ), ), diff --git a/netbox/templates/dcim/inventoryitemrole.html b/netbox/templates/dcim/inventoryitemrole.html new file mode 100644 index 00000000000..f750d74ce00 --- /dev/null +++ b/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 %} + +{% endblock %} + +{% block content %} +
+
+
+
Inventory Item Role
+
+ + + + + + + + + + + + + + + + + +
Name{{ object.name }}
Description{{ object.description|placeholder }}
Color +   +
Inventory Items + {{ inventoryitem_count }} +
+
+
+ {% include 'inc/panels/tags.html' %} + {% plugin_left_page object %} +
+
+ {% include 'inc/panels/custom_fields.html' %} + {% plugin_right_page object %} +
+
+
+
+ {% plugin_full_width_page object %} +
+
+{% endblock %} From 6e9afccfd79363d9862c4a99aff0e477c3b27e45 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:45:33 -0500 Subject: [PATCH 2/3] #8037: Add role field to InventoryItem --- docs/models/dcim/inventoryitem.md | 2 +- netbox/dcim/api/serializers.py | 3 +- netbox/dcim/filtersets.py | 10 +++++ netbox/dcim/forms/bulk_create.py | 6 +-- netbox/dcim/forms/bulk_edit.py | 8 +++- netbox/dcim/forms/bulk_import.py | 8 +++- netbox/dcim/forms/filtersets.py | 6 +++ netbox/dcim/forms/models.py | 8 +++- netbox/dcim/forms/object_create.py | 14 +++--- netbox/dcim/models/device_components.py | 16 +++---- netbox/dcim/tables/devices.py | 56 ++++++++++++------------ netbox/dcim/tests/test_api.py | 15 +++++-- netbox/dcim/tests/test_filtersets.py | 21 +++++++-- netbox/dcim/tests/test_views.py | 15 +++++-- netbox/dcim/views.py | 4 +- netbox/templates/dcim/inventoryitem.html | 14 ++++-- 16 files changed, 141 insertions(+), 65 deletions(-) diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index 237bad92cdf..f9837183392 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/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. -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. diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index fe84874111a..5e07ea3fd0e 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -811,12 +811,13 @@ class InventoryItemSerializer(PrimaryModelSerializer): device = NestedDeviceSerializer() parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), 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) class Meta: model = InventoryItem 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', ] diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5f4840fdee9..01c0a278d21 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1284,6 +1284,16 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet): 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)', + ) serial = django_filters.CharFilter( lookup_expr='iexact' ) diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py index 8eae46111e0..e78e0ee19af 100644 --- a/netbox/dcim/forms/bulk_create.py +++ b/netbox/dcim/forms/bulk_create.py @@ -107,11 +107,11 @@ class DeviceBayBulkCreateForm(DeviceBulkAddComponentForm): 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 ): model = InventoryItem 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', ) diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 8fc8835cbc0..93a90a1cb6a 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -1172,7 +1172,7 @@ class DeviceBayBulkEditForm( class InventoryItemBulkEditForm( - form_from_model(InventoryItem, ['label', 'manufacturer', 'part_id', 'description']), + form_from_model(InventoryItem, ['label', 'role', 'manufacturer', 'part_id', 'description']), AddRemoveTagsForm, CustomFieldModelBulkEditForm ): @@ -1180,13 +1180,17 @@ class InventoryItemBulkEditForm( queryset=InventoryItem.objects.all(), widget=forms.MultipleHiddenInput() ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False ) class Meta: - nullable_fields = ['label', 'manufacturer', 'part_id', 'description'] + nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] # diff --git a/netbox/dcim/forms/bulk_import.py b/netbox/dcim/forms/bulk_import.py index 40838c60ca1..1297fc98024 100644 --- a/netbox/dcim/forms/bulk_import.py +++ b/netbox/dcim/forms/bulk_import.py @@ -772,6 +772,11 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): queryset=Device.objects.all(), to_field_name='name' ) + role = CSVModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) manufacturer = CSVModelChoiceField( queryset=Manufacturer.objects.all(), to_field_name='name', @@ -787,7 +792,8 @@ class InventoryItemCSVForm(CustomFieldModelCSVForm): class Meta: model = InventoryItem 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): diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index ae58e1d2f15..c12891dc368 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1100,6 +1100,12 @@ class InventoryItemFilterForm(DeviceComponentFilterForm): ['name', 'label', 'manufacturer_id', 'serial', 'asset_tag', 'discovered'], ['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( queryset=Manufacturer.objects.all(), required=False, diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index b5b15e731b8..2be571f71eb 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1368,6 +1368,10 @@ class InventoryItemForm(CustomFieldModelForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) manufacturer = DynamicModelChoiceField( queryset=Manufacturer.objects.all(), required=False @@ -1380,8 +1384,8 @@ class InventoryItemForm(CustomFieldModelForm): class Meta: model = InventoryItem 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', ] diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py index 681a17a5aa9..9e208300b2d 100644 --- a/netbox/dcim/forms/object_create.py +++ b/netbox/dcim/forms/object_create.py @@ -652,10 +652,6 @@ class DeviceBayCreateForm(ComponentCreateForm): class InventoryItemCreateForm(ComponentCreateForm): model = InventoryItem - manufacturer = DynamicModelChoiceField( - queryset=Manufacturer.objects.all(), - required=False - ) parent = DynamicModelChoiceField( queryset=InventoryItem.objects.all(), required=False, @@ -663,6 +659,14 @@ class InventoryItemCreateForm(ComponentCreateForm): 'device_id': '$device' } ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) part_id = forms.CharField( max_length=50, required=False, @@ -677,6 +681,6 @@ class InventoryItemCreateForm(ComponentCreateForm): required=False, ) 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', ) diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 5329c9e0120..cb38d86830a 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -994,6 +994,13 @@ class InventoryItem(MPTTModel, ComponentModel): null=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( to='dcim.Manufacturer', on_delete=models.PROTECT, @@ -1007,13 +1014,6 @@ 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', @@ -1034,7 +1034,7 @@ class InventoryItem(MPTTModel, ComponentModel): objects = TreeManager() - clone_fields = ['device', 'parent', 'manufacturer', 'part_id', 'role'] + clone_fields = ['device', 'parent', 'role', 'manufacturer', 'part_id'] class Meta: ordering = ('device__id', 'parent__id', '_name') diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index f889d52ec37..9472be541cb 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -774,6 +774,9 @@ class InventoryItemTable(DeviceComponentTable): 'args': [Accessor('device_id')], } ) + role = tables.Column( + linkify=True + ) manufacturer = tables.Column( linkify=True ) @@ -786,10 +789,33 @@ class InventoryItemTable(DeviceComponentTable): class Meta(BaseTable.Meta): model = InventoryItem 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', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag') + + +class DeviceInventoryItemTable(InventoryItemTable): + name = tables.TemplateColumn( + template_code='' + '{{ value }}', + order_by=Accessor('_name'), + attrs={'td': {'class': 'text-nowrap'}} + ) + actions = ButtonsColumn( + model=InventoryItem, + buttons=('edit', 'delete') + ) + + class Meta(BaseTable.Meta): + model = InventoryItem + fields = ( + 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', + 'discovered', 'tags', 'actions', + ) + default_columns = ( + 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions', ) - default_columns = ('pk', 'name', 'device', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag') class InventoryItemRoleTable(BaseTable): @@ -816,30 +842,6 @@ class InventoryItemRoleTable(BaseTable): default_columns = ('pk', 'name', 'inventoryitem_count', 'color', 'description', 'actions') -class DeviceInventoryItemTable(InventoryItemTable): - name = tables.TemplateColumn( - template_code='' - '{{ value }}', - order_by=Accessor('_name'), - attrs={'td': {'class': 'text-nowrap'}} - ) - actions = ButtonsColumn( - model=InventoryItem, - buttons=('edit', 'delete') - ) - - class Meta(BaseTable.Meta): - model = InventoryItem - fields = ( - 'pk', 'id', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'tags', 'actions', - ) - default_columns = ( - 'pk', 'name', 'label', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'discovered', - 'actions', - ) - - # # Virtual chassis # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index a6c7760f4e8..e6ea1049975 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1626,24 +1626,33 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase): 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) - 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 = [ { 'device': device.pk, 'name': 'Inventory Item 4', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, { 'device': device.pk, 'name': 'Inventory Item 5', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, { 'device': device.pk, 'name': 'Inventory Item 6', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, }, ] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f93e9164d22..a808aeda2a0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2949,7 +2949,6 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): @classmethod def setUpTestData(cls): - manufacturers = ( Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), @@ -2998,10 +2997,17 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): ) 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 = ( - 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: i.save() @@ -3077,6 +3083,13 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests): 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]} diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 3ac7b9c7211..8f077df92b0 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -2331,14 +2331,21 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): device = create_test_device('Device 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') cls.form_data = { 'device': device.pk, + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'name': 'Inventory Item X', 'parent': None, @@ -2353,6 +2360,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): cls.bulk_create_data = { 'device': device.pk, 'name_pattern': 'Inventory Item [4-6]', + 'role': roles[1].pk, 'manufacturer': manufacturer.pk, 'parent': None, 'discovered': False, @@ -2363,6 +2371,7 @@ class InventoryItemTestCase(ViewTestCases.DeviceComponentViewTestCase): } cls.bulk_edit_data = { + 'role': roles[1].pk, 'part_id': '123456', 'description': 'New description', } diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 8e3d35b3e6b..bfa2fecae82 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -2412,7 +2412,7 @@ class InventoryItemBulkImportView(generic.BulkImportView): class InventoryItemBulkEditView(generic.BulkEditView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') filterset = filtersets.InventoryItemFilterSet table = tables.InventoryItemTable form = forms.InventoryItemBulkEditForm @@ -2423,7 +2423,7 @@ class InventoryItemBulkRenameView(generic.BulkRenameView): class InventoryItemBulkDeleteView(generic.BulkDeleteView): - queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer') + queryset = InventoryItem.objects.prefetch_related('device', 'manufacturer', 'role') table = tables.InventoryItemTable template_name = 'dcim/inventoryitem_bulk_delete.html' diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html index 36ba0469f89..7de30365602 100644 --- a/netbox/templates/dcim/inventoryitem.html +++ b/netbox/templates/dcim/inventoryitem.html @@ -13,9 +13,7 @@
-
- Inventory Item -
+
Inventory Item
@@ -42,6 +40,16 @@ + + + +
Label {{ object.label|placeholder }}
Role + {% if object.role %} + {{ object.role }} + {% else %} + + {% endif %} +
Manufacturer From a748083f2606b28baf9ed2cf2a9c4e48d92480bd Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 27 Dec 2021 10:52:04 -0500 Subject: [PATCH 3/3] Changelog for #3087 --- docs/release-notes/version-3.2.md | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index f35100c7286..608a436a16e 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -18,6 +18,10 @@ A new REST API endpoint has been added at `/api/ipam/vlan-groups//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)) 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 -* Added the following endpoints for modules & module types: +* Added the following endpoints: + * `/api/dcim/inventory-item-roles/` * `/api/dcim/modules/` * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` @@ -70,6 +75,8 @@ FIELD_CHOICES = { * Added `module` field * dcim.Interface * Added `module` field +* dcim.InventoryItem + * Added `role` field * dcim.PowerPort * Added `module` field * dcim.PowerOutlet