#3087: Add InvetoryItemRole

This commit is contained in:
jeremystretch 2021-12-27 10:18:39 -05:00
parent 77dd684916
commit 04fb5e544d
22 changed files with 469 additions and 10 deletions

View File

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

View File

@ -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
#

View File

@ -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
#

View File

@ -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)

View File

@ -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
#

View File

@ -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',

View File

@ -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']

View File

@ -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(),

View File

@ -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
#

View File

@ -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',
]

View File

@ -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)

View File

@ -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:

View File

@ -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'),
),
]

View File

@ -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')

View File

@ -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">'

View File

@ -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']

View File

@ -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

View File

@ -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(

View File

@ -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'),

View File

@ -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
#

View File

@ -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'),
),
),
),

View File

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