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 819cb91cc..ae58e1d2f 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 2d32093c4..b5b15e731 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 7f660b192..8e03ab409 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 51e196076..b2a94c3ed 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 000000000..97de677f8
--- /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 ccfe538d7..5329c9e01 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 7805e60c1..f889d52ec 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 3b6410c8c..a6c7760f4 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 8f04fb4d9..f93e9164d 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 12216a8ac..3ac7b9c72 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 8ec30c0cc..d45ce7577 100644
--- a/netbox/dcim/urls.py
+++ b/netbox/dcim/urls.py
@@ -425,6 +425,17 @@ urlpatterns = [
path('inventory-items/Name | +{{ object.name }} | +
---|---|
Description | +{{ object.description|placeholder }} | +
Color | ++ + | +
Inventory Items | ++ {{ inventoryitem_count }} + | +