diff --git a/docs/models/dcim/devicetype.md b/docs/models/dcim/devicetype.md index b919465c8b..cf42185f49 100644 --- a/docs/models/dcim/devicetype.md +++ b/docs/models/dcim/devicetype.md @@ -4,13 +4,13 @@ A device type represents a particular make and model of hardware that exists in Device types are instantiated as devices installed within sites and/or equipment racks. For example, you might define a device type to represent a Juniper EX4300-48T network switch with 48 Ethernet interfaces. You can then create multiple _instances_ of this type named "switch1," "switch2," and so on. Each device will automatically inherit the components (such as interfaces) of its device type at the time of creation. However, changes made to a device type will **not** apply to instances of that device type retroactively. -Some devices house child devices which share physical resources, like space and power, but which functional independently from one another. A common example of this is blade server chassis. Each device type is designated as one of the following: +Some devices house child devices which share physical resources, like space and power, but which function independently. A common example of this is blade server chassis. Each device type is designated as one of the following: * A parent device (which has device bays) * A child device (which must be installed within a device bay) * Neither !!! note - This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as inventory items within a device, with any associated interfaces or other components assigned directly to the device. + This parent/child relationship is **not** suitable for modeling chassis-based devices, wherein child members share a common control plane. Instead, line cards and similarly non-autonomous hardware should be modeled as modules or inventory items within a device. A device type may optionally specify an airflow direction, such as front-to-rear, rear-to-front, or passive. Airflow direction may also be set separately per device. If it is not defined for a device at the time of its creation, it will inherit the airflow setting of its device type. diff --git a/docs/models/dcim/inventoryitem.md b/docs/models/dcim/inventoryitem.md index f983718339..fbd3172bbe 100644 --- a/docs/models/dcim/inventoryitem.md +++ b/docs/models/dcim/inventoryitem.md @@ -1,7 +1,7 @@ # Inventory Items -Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. Inventory items are distinct from other device components in that they cannot be templatized on a device type, and cannot be connected by cables. They are intended to be used primarily for inventory purposes. +Inventory items represent hardware components installed within a device, such as a power supply or CPU or line card. They are intended to be used primarily for inventory purposes. -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). +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 NetBox). -Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. +Inventory items are hierarchical in nature, such that any individual item may be designated as the parent for other items. For example, an inventory item might be created to represent a line card which houses several SFP optics, each of which exists as a child item within the device. An inventory item may also be associated with a specific component within the same device. For example, you may wish to associate a transceiver with an interface. diff --git a/docs/models/dcim/inventoryitemtemplate.md b/docs/models/dcim/inventoryitemtemplate.md new file mode 100644 index 0000000000..3167ed4abf --- /dev/null +++ b/docs/models/dcim/inventoryitemtemplate.md @@ -0,0 +1,3 @@ +# Inventory Item Templates + +A template for an inventory item that will be automatically created when instantiating a new device. All attributes of this object will be copied to the new inventory item, including the associations with a parent item and assigned component, if any. diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md index e0db0b13b9..d2702cfdaa 100644 --- a/docs/release-notes/version-3.2.md +++ b/docs/release-notes/version-3.2.md @@ -42,6 +42,12 @@ FIELD_CHOICES = { } ``` +#### Inventory Item Templates ([#8118](https://github.com/netbox-community/netbox/issues/8118)) + +Inventory items can now be templatized on a device type similar to the other component types. This enables users to better pre-model fixed hardware components. + +Inventory item templates can be arranged hierarchically within a device type, and may be assigned to other components. These relationships will be mirrored when instantiating inventory items on a newly-created device. + ### Enhancements * [#7650](https://github.com/netbox-community/netbox/issues/7650) - Add support for local account password validation @@ -62,6 +68,7 @@ FIELD_CHOICES = { * Added the following endpoints: * `/api/dcim/inventory-item-roles/` + * `/api/dcim/inventory-item-templates/` * `/api/dcim/modules/` * `/api/dcim/module-bays/` * `/api/dcim/module-bay-templates/` diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0cd112a1d8..0ec0e07e0c 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -21,6 +21,7 @@ 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', 'NestedInventoryItemRoleSerializer', + 'NestedInventoryItemTemplateSerializer', 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', @@ -231,6 +232,15 @@ class Meta: fields = ['id', 'url', 'display', 'name'] +class NestedInventoryItemTemplateSerializer(WritableNestedSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = models.InventoryItemTemplate + fields = ['id', 'url', 'display', 'name', '_depth'] + + # # Devices # diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 30f451e845..3bc369a640 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -447,6 +447,40 @@ class Meta: fields = ['id', 'url', 'display', 'device_type', 'name', 'label', 'description', 'created', 'last_updated'] +class InventoryItemTemplateSerializer(ValidatedModelSerializer): + url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitemtemplate-detail') + device_type = NestedDeviceTypeSerializer() + parent = serializers.PrimaryKeyRelatedField( + queryset=InventoryItemTemplate.objects.all(), + allow_null=True, + default=None + ) + role = NestedInventoryItemRoleSerializer(required=False, allow_null=True) + manufacturer = NestedManufacturerSerializer(required=False, allow_null=True, default=None) + component_type = ContentTypeField( + queryset=ContentType.objects.filter(MODULAR_COMPONENT_TEMPLATE_MODELS), + required=False, + allow_null=True + ) + component = serializers.SerializerMethodField(read_only=True) + _depth = serializers.IntegerField(source='level', read_only=True) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'id', 'url', 'display', 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', + 'description', 'component_type', 'component_id', 'component', 'created', 'last_updated', '_depth', + ] + + @swagger_serializer_method(serializer_or_field=serializers.DictField) + def get_component(self, obj): + if obj.component is None: + return None + serializer = get_serializer_for_model(obj.component, prefix='Nested') + context = {'request': self.context['request']} + return serializer(obj.component, context=context).data + + # # Devices # diff --git a/netbox/dcim/api/urls.py b/netbox/dcim/api/urls.py index be963d36df..c6f48aed3a 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -31,6 +31,7 @@ router.register('rear-port-templates', views.RearPortTemplateViewSet) router.register('module-bay-templates', views.ModuleBayTemplateViewSet) router.register('device-bay-templates', views.DeviceBayTemplateViewSet) +router.register('inventory-item-templates', views.InventoryItemTemplateViewSet) # Device/modules router.register('device-roles', views.DeviceRoleViewSet) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 479abf7b2e..31c1fd1d08 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -350,6 +350,12 @@ class DeviceBayTemplateViewSet(ModelViewSet): filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateViewSet(ModelViewSet): + queryset = InventoryItemTemplate.objects.prefetch_related('device_type__manufacturer', 'role') + serializer_class = serializers.InventoryItemTemplateSerializer + filterset_class = filtersets.InventoryItemTemplateFilterSet + + # # Device roles # diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py index 00126ebf82..45844b0491 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,18 @@ # Device components # +MODULAR_COMPONENT_TEMPLATE_MODELS = Q( + app_label='dcim', + model__in=( + 'consoleporttemplate', + 'consoleserverporttemplate', + 'frontporttemplate', + 'interfacetemplate', + 'poweroutlettemplate', + 'powerporttemplate', + 'rearporttemplate', + )) + MODULAR_COMPONENT_MODELS = Q( app_label='dcim', model__in=( diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 14a2ae3eea..9069ab25ca 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -40,6 +40,7 @@ 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'InventoryItemRoleFilterSet', + 'InventoryItemTemplateFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', 'ModuleBayFilterSet', @@ -687,6 +688,49 @@ class Meta: fields = ['id', 'name'] +class InventoryItemTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponentFilterSet): + parent_id = django_filters.ModelMultipleChoiceFilter( + queryset=InventoryItemTemplate.objects.all(), + label='Parent inventory item (ID)', + ) + manufacturer_id = django_filters.ModelMultipleChoiceFilter( + queryset=Manufacturer.objects.all(), + label='Manufacturer (ID)', + ) + manufacturer = django_filters.ModelMultipleChoiceFilter( + field_name='manufacturer__slug', + queryset=Manufacturer.objects.all(), + 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)', + ) + component_type = ContentTypeFilter() + component_id = MultiValueNumberFilter() + + class Meta: + model = InventoryItemTemplate + fields = ['id', 'name', 'label', 'part_id'] + + def search(self, queryset, name, value): + if not value.strip(): + return queryset + qs_filter = ( + Q(name__icontains=value) | + Q(part_id__icontains=value) | + Q(description__icontains=value) + ) + return queryset.filter(qs_filter) + + class DeviceRoleFilterSet(OrganizationalModelFilterSet): tag = TagFilter() diff --git a/netbox/dcim/forms/bulk_edit.py b/netbox/dcim/forms/bulk_edit.py index 93a90a1cb6..3cd8ec35e7 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -31,6 +31,7 @@ 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', 'InventoryItemRoleBulkEditForm', + 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', 'ModuleBulkEditForm', @@ -907,6 +908,31 @@ class Meta: nullable_fields = ('label', 'description') +class InventoryItemTemplateBulkEditForm(BulkEditForm): + pk = forms.ModelMultipleChoiceField( + queryset=InventoryItemTemplate.objects.all(), + widget=forms.MultipleHiddenInput() + ) + label = forms.CharField( + max_length=64, + required=False + ) + description = forms.CharField( + required=False + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + + class Meta: + nullable_fields = ['label', 'role', 'manufacturer', 'part_id', 'description'] + + # # Device components # diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index e2c343028f..65b7d46a8f 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -38,6 +38,7 @@ 'InterfaceTemplateForm', 'InventoryItemForm', 'InventoryItemRoleForm', + 'InventoryItemTemplateForm', 'LocationForm', 'ManufacturerForm', 'ModuleForm', @@ -1073,6 +1074,48 @@ class Meta: } +class InventoryItemTemplateForm(BootstrapMixin, forms.ModelForm): + parent = DynamicModelChoiceField( + queryset=InventoryItem.objects.all(), + required=False, + query_params={ + 'device_id': '$device' + } + ) + role = DynamicModelChoiceField( + queryset=InventoryItemRole.objects.all(), + required=False + ) + manufacturer = DynamicModelChoiceField( + queryset=Manufacturer.objects.all(), + required=False + ) + component_type = ContentTypeChoiceField( + queryset=ContentType.objects.all(), + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + required=False, + widget=forms.HiddenInput + ) + component_id = forms.IntegerField( + required=False, + widget=forms.HiddenInput + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + 'component_type', 'component_id', + ] + fieldsets = ( + ('Inventory Item', ('device_type', 'parent', 'name', 'label', 'role', 'description')), + ('Hardware', ('manufacturer', 'part_id')), + ) + widgets = { + 'device_type': forms.HiddenInput(), + } + + # # Device components # diff --git a/netbox/dcim/forms/object_import.py b/netbox/dcim/forms/object_import.py index 36c6ae8bc8..afbcd65431 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,7 @@ 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', @@ -49,24 +50,7 @@ class Meta: # class ComponentTemplateImportForm(BootstrapMixin, forms.ModelForm): - - def clean_device_type(self): - # Limit fields referencing other components to the parent DeviceType - if data := self.cleaned_data['device_type']: - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: - field.queryset = field.queryset.filter(device_type=data) - - return data - - def clean_module_type(self): - # Limit fields referencing other components to the parent ModuleType - if data := self.cleaned_data['module_type']: - for field_name, field in self.fields.items(): - if isinstance(field, forms.ModelChoiceField) and field_name not in ['device_type', 'module_type']: - field.queryset = field.queryset.filter(module_type=data) - - return data + pass class ConsolePortTemplateImportForm(ComponentTemplateImportForm): @@ -109,6 +93,20 @@ class Meta: 'device_type', 'module_type', 'name', 'label', 'type', 'power_port', 'feed_leg', 'description', ] + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + power_port = self.fields['power_port'] + power_port.queryset = power_port.queryset.filter(module_type=module_type) + + return module_type + class InterfaceTemplateImportForm(ComponentTemplateImportForm): type = forms.ChoiceField( @@ -131,6 +129,20 @@ class FrontPortTemplateImportForm(ComponentTemplateImportForm): to_field_name='name' ) + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + rear_port = self.fields['rear_port'] + rear_port.queryset = rear_port.queryset.filter(module_type=module_type) + + return module_type + class Meta: model = FrontPortTemplate fields = [ @@ -166,3 +178,40 @@ class Meta: fields = [ 'device_type', 'name', 'label', 'description', ] + + +class InventoryItemTemplateImportForm(ComponentTemplateImportForm): + parent = forms.ModelChoiceField( + queryset=InventoryItemTemplate.objects.all(), + required=False + ) + role = forms.ModelChoiceField( + queryset=InventoryItemRole.objects.all(), + to_field_name='name', + required=False + ) + manufacturer = forms.ModelChoiceField( + queryset=Manufacturer.objects.all(), + to_field_name='name', + required=False + ) + + class Meta: + model = InventoryItemTemplate + fields = [ + 'device_type', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'description', + ] + + def clean_device_type(self): + if device_type := self.cleaned_data['device_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(device_type=device_type) + + return device_type + + def clean_module_type(self): + if module_type := self.cleaned_data['module_type']: + parent = self.fields['parent'] + parent.queryset = parent.queryset.filter(module_type=module_type) + + return module_type diff --git a/netbox/dcim/graphql/schema.py b/netbox/dcim/graphql/schema.py index 8e03ab409a..1d5b6a5809 100644 --- a/netbox/dcim/graphql/schema.py +++ b/netbox/dcim/graphql/schema.py @@ -53,6 +53,9 @@ class DCIMQuery(graphene.ObjectType): inventory_item_role = ObjectField(InventoryItemRoleType) inventory_item_role_list = ObjectListField(InventoryItemRoleType) + inventory_item_template = ObjectField(InventoryItemTemplateType) + inventory_item_template_list = ObjectListField(InventoryItemTemplateType) + location = ObjectField(LocationType) location_list = ObjectListField(LocationType) diff --git a/netbox/dcim/graphql/types.py b/netbox/dcim/graphql/types.py index b2a94c3ed4..a47ca40ca0 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -26,6 +26,7 @@ 'InterfaceTemplateType', 'InventoryItemType', 'InventoryItemRoleType', + 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', 'ModuleType', @@ -172,6 +173,14 @@ class Meta: filterset_class = filtersets.DeviceBayTemplateFilterSet +class InventoryItemTemplateType(ComponentTemplateObjectType): + + class Meta: + model = models.InventoryItemTemplate + fields = '__all__' + filterset_class = filtersets.InventoryItemTemplateFilterSet + + class DeviceRoleType(OrganizationalObjectType): class Meta: diff --git a/netbox/dcim/migrations/0148_inventoryitem_templates.py b/netbox/dcim/migrations/0148_inventoryitem_templates.py new file mode 100644 index 0000000000..8c3fe78c37 --- /dev/null +++ b/netbox/dcim/migrations/0148_inventoryitem_templates.py @@ -0,0 +1,43 @@ +from django.db import migrations, models +import django.db.models.deletion +import mptt.fields +import utilities.fields +import utilities.ordering + + +class Migration(migrations.Migration): + + dependencies = [ + ('contenttypes', '0002_remove_content_type_name'), + ('dcim', '0147_inventoryitem_component'), + ] + + operations = [ + migrations.CreateModel( + name='InventoryItemTemplate', + fields=[ + ('created', models.DateField(auto_now_add=True, null=True)), + ('last_updated', models.DateTimeField(auto_now=True, null=True)), + ('id', models.BigAutoField(primary_key=True, serialize=False)), + ('name', models.CharField(max_length=64)), + ('_name', utilities.fields.NaturalOrderingField('name', blank=True, max_length=100, naturalize_function=utilities.ordering.naturalize)), + ('label', models.CharField(blank=True, max_length=64)), + ('description', models.CharField(blank=True, max_length=200)), + ('component_id', models.PositiveBigIntegerField(blank=True, null=True)), + ('part_id', models.CharField(blank=True, max_length=50)), + ('lft', models.PositiveIntegerField(editable=False)), + ('rght', models.PositiveIntegerField(editable=False)), + ('tree_id', models.PositiveIntegerField(db_index=True, editable=False)), + ('level', models.PositiveIntegerField(editable=False)), + ('component_type', models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleporttemplate', 'consoleserverporttemplate', 'frontporttemplate', 'interfacetemplate', 'poweroutlettemplate', 'powerporttemplate', 'rearporttemplate'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype')), + ('device_type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='inventoryitemtemplates', to='dcim.devicetype')), + ('manufacturer', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.manufacturer')), + ('parent', mptt.fields.TreeForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='child_items', to='dcim.inventoryitemtemplate')), + ('role', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='inventory_item_templates', to='dcim.inventoryitemrole')), + ], + options={ + 'ordering': ('device_type__id', 'parent__id', '_name'), + 'unique_together': {('device_type', 'parent', 'name')}, + }, + ), + ] diff --git a/netbox/dcim/models/device_component_templates.py b/netbox/dcim/models/device_component_templates.py index 71fed25c58..b3ede82822 100644 --- a/netbox/dcim/models/device_component_templates.py +++ b/netbox/dcim/models/device_component_templates.py @@ -1,15 +1,20 @@ +from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.validators import MaxValueValidator, MinValueValidator from django.db import models +from mptt.models import MPTTModel, TreeForeignKey from dcim.choices import * from dcim.constants import * from extras.utils import extras_features from netbox.models import ChangeLoggedModel from utilities.fields import ColorField, NaturalOrderingField +from utilities.mptt import TreeManager from utilities.ordering import naturalize_interface from .device_components import ( - ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, ModuleBay, PowerOutlet, PowerPort, RearPort, + ConsolePort, ConsoleServerPort, DeviceBay, FrontPort, Interface, InventoryItem, ModuleBay, PowerOutlet, PowerPort, + RearPort, ) @@ -19,6 +24,7 @@ 'DeviceBayTemplate', 'FrontPortTemplate', 'InterfaceTemplate', + 'InventoryItemTemplate', 'ModuleBayTemplate', 'PowerOutletTemplate', 'PowerPortTemplate', @@ -140,6 +146,8 @@ class ConsolePortTemplate(ModularComponentTemplateModel): blank=True ) + component_model = ConsolePort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -148,7 +156,7 @@ class Meta: ) def instantiate(self, **kwargs): - return ConsolePort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -167,6 +175,8 @@ class ConsoleServerPortTemplate(ModularComponentTemplateModel): blank=True ) + component_model = ConsoleServerPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -175,7 +185,7 @@ class Meta: ) def instantiate(self, **kwargs): - return ConsoleServerPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -206,6 +216,8 @@ class PowerPortTemplate(ModularComponentTemplateModel): help_text="Allocated power draw (watts)" ) + component_model = PowerPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -214,7 +226,7 @@ class Meta: ) def instantiate(self, **kwargs): - return PowerPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -257,6 +269,8 @@ class PowerOutletTemplate(ModularComponentTemplateModel): help_text="Phase (for three-phase feeds)" ) + component_model = PowerOutlet + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -283,7 +297,7 @@ def instantiate(self, **kwargs): power_port = PowerPort.objects.get(name=self.power_port.name, **kwargs) else: power_port = None - return PowerOutlet( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -314,6 +328,8 @@ class InterfaceTemplate(ModularComponentTemplateModel): verbose_name='Management only' ) + component_model = Interface + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -322,7 +338,7 @@ class Meta: ) def instantiate(self, **kwargs): - return Interface( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -356,6 +372,8 @@ class FrontPortTemplate(ModularComponentTemplateModel): ] ) + component_model = FrontPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -391,7 +409,7 @@ def instantiate(self, **kwargs): rear_port = RearPort.objects.get(name=self.rear_port.name, **kwargs) else: rear_port = None - return FrontPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -422,6 +440,8 @@ class RearPortTemplate(ModularComponentTemplateModel): ] ) + component_model = RearPort + class Meta: ordering = ('device_type', 'module_type', '_name') unique_together = ( @@ -430,7 +450,7 @@ class Meta: ) def instantiate(self, **kwargs): - return RearPort( + return self.component_model( name=self.resolve_name(kwargs.get('module')), label=self.label, type=self.type, @@ -451,12 +471,14 @@ class ModuleBayTemplate(ComponentTemplateModel): help_text='Identifier to reference when renaming installed components' ) + component_model = ModuleBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return ModuleBay( + return self.component_model( device=device, name=self.name, label=self.label, @@ -469,12 +491,14 @@ class DeviceBayTemplate(ComponentTemplateModel): """ A template for a DeviceBay to be created for a new parent Device. """ + component_model = DeviceBay + class Meta: ordering = ('device_type', '_name') unique_together = ('device_type', 'name') def instantiate(self, device): - return DeviceBay( + return self.component_model( device=device, name=self.name, label=self.label @@ -485,3 +509,79 @@ def clean(self): raise ValidationError( f"Subdevice role of device type ({self.device_type}) must be set to \"parent\" to allow device bays." ) + + +@extras_features('webhooks') +class InventoryItemTemplate(MPTTModel, ComponentTemplateModel): + """ + A template for an InventoryItem to be created for a new parent Device. + """ + parent = TreeForeignKey( + to='self', + on_delete=models.CASCADE, + related_name='child_items', + blank=True, + null=True, + db_index=True + ) + component_type = models.ForeignKey( + to=ContentType, + limit_choices_to=MODULAR_COMPONENT_TEMPLATE_MODELS, + on_delete=models.PROTECT, + related_name='+', + blank=True, + null=True + ) + component_id = models.PositiveBigIntegerField( + blank=True, + null=True + ) + component = GenericForeignKey( + ct_field='component_type', + fk_field='component_id' + ) + role = models.ForeignKey( + to='dcim.InventoryItemRole', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + manufacturer = models.ForeignKey( + to='dcim.Manufacturer', + on_delete=models.PROTECT, + related_name='inventory_item_templates', + blank=True, + null=True + ) + part_id = models.CharField( + max_length=50, + verbose_name='Part ID', + blank=True, + help_text='Manufacturer-assigned part identifier' + ) + + objects = TreeManager() + component_model = InventoryItem + + class Meta: + ordering = ('device_type__id', 'parent__id', '_name') + unique_together = ('device_type', 'parent', 'name') + + def instantiate(self, **kwargs): + parent = InventoryItemTemplate.objects.get(name=self.parent.name, **kwargs) if self.parent else None + if self.component: + model = self.component.component_model + component = model.objects.get(name=self.component.name, **kwargs) + else: + component = None + return self.component_model( + parent=parent, + name=self.name, + label=self.label, + component=component, + role=self.role, + manufacturer=self.manufacturer, + part_id=self.part_id, + **kwargs + ) diff --git a/netbox/dcim/models/devices.py b/netbox/dcim/models/devices.py index 8d0a7ae19e..631f0c8c1d 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -933,6 +933,9 @@ def save(self, *args, **kwargs): DeviceBay.objects.bulk_create( [x.instantiate(device=self) for x in self.device_type.devicebaytemplates.all()] ) + # Avoid bulk_create to handle MPTT + for x in self.device_type.inventoryitemtemplates.all(): + x.instantiate(device=self).save() # Update Site and Rack assignment for any child Devices devices = Device.objects.filter(parent_bay__device=self) diff --git a/netbox/dcim/tables/devicetypes.py b/netbox/dcim/tables/devicetypes.py index ad4c4d8441..525c690307 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,12 +1,14 @@ import django_tables2 as tables +from django_tables2.utils import Accessor from dcim.models import ( ConsolePortTemplate, ConsoleServerPortTemplate, DeviceBayTemplate, DeviceType, FrontPortTemplate, InterfaceTemplate, - Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, + InventoryItemTemplate, Manufacturer, ModuleBayTemplate, PowerOutletTemplate, PowerPortTemplate, RearPortTemplate, ) from utilities.tables import ( BaseTable, BooleanColumn, ButtonsColumn, ColorColumn, LinkedCountColumn, MarkdownColumn, TagColumn, ToggleColumn, ) +from .template_code import MODULAR_COMPONENT_TEMPLATE_BUTTONS __all__ = ( 'ConsolePortTemplateTable', @@ -15,6 +17,7 @@ 'DeviceTypeTable', 'FrontPortTemplateTable', 'InterfaceTemplateTable', + 'InventoryItemTemplateTable', 'ManufacturerTable', 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', @@ -112,7 +115,8 @@ class Meta(BaseTable.Meta): class ConsolePortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsolePortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -124,7 +128,8 @@ class Meta(ComponentTemplateTable.Meta): class ConsoleServerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=ConsoleServerPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -136,7 +141,8 @@ class Meta(ComponentTemplateTable.Meta): class PowerPortTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -148,7 +154,8 @@ class Meta(ComponentTemplateTable.Meta): class PowerOutletTemplateTable(ComponentTemplateTable): actions = ButtonsColumn( model=PowerOutletTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -163,7 +170,8 @@ class InterfaceTemplateTable(ComponentTemplateTable): ) actions = ButtonsColumn( model=InterfaceTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -179,7 +187,8 @@ class FrontPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=FrontPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -192,7 +201,8 @@ class RearPortTemplateTable(ComponentTemplateTable): color = ColorColumn() actions = ButtonsColumn( model=RearPortTemplate, - buttons=('edit', 'delete') + buttons=('edit', 'delete'), + prepend_template=MODULAR_COMPONENT_TEMPLATE_BUTTONS ) class Meta(ComponentTemplateTable.Meta): @@ -223,3 +233,25 @@ class Meta(ComponentTemplateTable.Meta): model = DeviceBayTemplate fields = ('pk', 'name', 'label', 'description', 'actions') empty_text = "None" + + +class InventoryItemTemplateTable(ComponentTemplateTable): + actions = ButtonsColumn( + model=InventoryItemTemplate, + buttons=('edit', 'delete') + ) + role = tables.Column( + linkify=True + ) + manufacturer = tables.Column( + linkify=True + ) + component = tables.Column( + accessor=Accessor('component'), + orderable=False + ) + + class Meta(ComponentTemplateTable.Meta): + model = InventoryItemTemplate + fields = ('pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'component', 'description', 'actions') + empty_text = "None" diff --git a/netbox/dcim/tables/template_code.py b/netbox/dcim/tables/template_code.py index 6b44c4b3f0..2b6c02b829 100644 --- a/netbox/dcim/tables/template_code.py +++ b/netbox/dcim/tables/template_code.py @@ -93,6 +93,19 @@ """ +# +# Device component templatebuttons +# + +MODULAR_COMPONENT_TEMPLATE_BUTTONS = """ +{% load helpers %} +{% if perms.dcim.add_invnetoryitemtemplate %} + + + +{% endif %} +""" + # # Device component buttons # diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index b3c41e277f..0c9b918dff 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -897,6 +897,57 @@ def setUpTestData(cls): ] +class InventoryItemTemplateTest(APIViewTestCases.APIViewTestCase): + model = InventoryItemTemplate + brief_fields = ['_depth', 'display', 'id', 'name', 'url'] + bulk_update_data = { + 'description': 'New description', + } + + @classmethod + def setUpTestData(cls): + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, + model='Device Type 1', + slug='device-type-1' + ) + role = InventoryItemRole.objects.create(name='Inventory Item Role 1', slug='inventory-item-role-1') + + inventory_item_templates = ( + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 1', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 2', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 3', manufacturer=manufacturer, role=role), + InventoryItemTemplate(device_type=devicetype, name='Inventory Item Template 4', manufacturer=manufacturer, role=role), + ) + for item in inventory_item_templates: + item.save() + + cls.create_data = [ + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 5', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 6', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + { + 'device_type': devicetype.pk, + 'name': 'Inventory Item Template 7', + 'manufacturer': manufacturer.pk, + 'role': role.pk, + 'parent': inventory_item_templates[3].pk, + }, + ] + + class DeviceRoleTest(APIViewTestCases.APIViewTestCase): model = DeviceRole brief_fields = ['device_count', 'display', 'id', 'name', 'slug', 'url', 'virtualmachine_count'] diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index f53705336b..2973e46e71 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1214,6 +1214,86 @@ def test_devicetype_id(self): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) +class InventoryItemTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): + queryset = InventoryItemTemplate.objects.all() + filterset = InventoryItemTemplateFilterSet + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + Manufacturer(name='Manufacturer 3', slug='manufacturer-3'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + device_types = ( + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1'), + DeviceType(manufacturer=manufacturers[0], model='Model 2', slug='model-2'), + DeviceType(manufacturer=manufacturers[0], model='Model 3', slug='model-3'), + ) + DeviceType.objects.bulk_create(device_types) + + inventory_item_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(inventory_item_roles) + + inventory_item_templates = ( + InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1', label='A', role=inventory_item_roles[0], manufacturer=manufacturers[0], part_id='1001'), + InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2', label='B', role=inventory_item_roles[1], manufacturer=manufacturers[1], part_id='1002'), + InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3', label='C', role=inventory_item_roles[2], manufacturer=manufacturers[2], part_id='1003'), + ) + for item in inventory_item_templates: + item.save() + + child_inventory_item_templates = ( + InventoryItemTemplate(device_type=device_types[0], name='Inventory Item 1A', parent=inventory_item_templates[0]), + InventoryItemTemplate(device_type=device_types[1], name='Inventory Item 2A', parent=inventory_item_templates[1]), + InventoryItemTemplate(device_type=device_types[2], name='Inventory Item 3A', parent=inventory_item_templates[2]), + ) + for item in child_inventory_item_templates: + item.save() + + def test_name(self): + params = {'name': ['Inventory Item 1', 'Inventory Item 2']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_devicetype_id(self): + device_types = DeviceType.objects.all()[:2] + params = {'devicetype_id': [device_types[0].pk, device_types[1].pk]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def test_label(self): + params = {'label': ['A', 'B']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_part_id(self): + params = {'part_id': ['1001', '1002']} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_parent_id(self): + parent_items = InventoryItemTemplate.objects.filter(parent__isnull=True)[:2] + 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]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + class DeviceRoleTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = DeviceRole.objects.all() filterset = DeviceRoleFilterSet diff --git a/netbox/dcim/tests/test_views.py b/netbox/dcim/tests/test_views.py index 8f077df92b..8f7cb606b8 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -580,6 +580,20 @@ def test_devicetype_devicebays(self): url = reverse('dcim:devicetype_devicebays', kwargs={'pk': devicetype.pk}) self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) + def test_devicetype_inventoryitems(self): + devicetype = DeviceType.objects.first() + inventory_items = ( + DeviceBayTemplate(device_type=devicetype, name='Device Bay 1'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 2'), + DeviceBayTemplate(device_type=devicetype, name='Device Bay 3'), + ) + for inventory_item in inventory_items: + inventory_item.save() + + url = reverse('dcim:devicetype_inventoryitems', kwargs={'pk': devicetype.pk}) + self.assertHttpStatus(self.client.get(url), 200) + @override_settings(EXEMPT_VIEW_PERMISSIONS=['*']) def test_import_objects(self): """ @@ -659,6 +673,13 @@ def test_import_objects(self): - name: Device Bay 1 - name: Device Bay 2 - name: Device Bay 3 +inventory-items: + - name: Inventory Item 1 + manufacturer: Generic + - name: Inventory Item 2 + manufacturer: Generic + - name: Inventory Item 3 + manufacturer: Generic """ # Create the manufacturer @@ -677,6 +698,7 @@ def test_import_objects(self): 'dcim.add_rearporttemplate', 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', + 'dcim.add_inventoryitemtemplate', ) form_data = { @@ -729,13 +751,17 @@ def test_import_objects(self): self.assertEqual(fp1.rear_port_position, 1) self.assertEqual(device_type.modulebaytemplates.count(), 3) - db1 = ModuleBayTemplate.objects.first() - self.assertEqual(db1.name, 'Module Bay 1') + mb1 = ModuleBayTemplate.objects.first() + self.assertEqual(mb1.name, 'Module Bay 1') self.assertEqual(device_type.devicebaytemplates.count(), 3) db1 = DeviceBayTemplate.objects.first() self.assertEqual(db1.name, 'Device Bay 1') + self.assertEqual(device_type.inventoryitemtemplates.count(), 3) + ii1 = InventoryItemTemplate.objects.first() + self.assertEqual(ii1.name, 'Inventory Item 1') + def test_export_objects(self): url = reverse('dcim:devicetype_list') self.add_permissions('dcim.view_devicetype') @@ -1393,6 +1419,48 @@ def setUpTestData(cls): } +class InventoryItemTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCase): + model = InventoryItemTemplate + + @classmethod + def setUpTestData(cls): + manufacturers = ( + Manufacturer(name='Manufacturer 1', slug='manufacturer-1'), + Manufacturer(name='Manufacturer 2', slug='manufacturer-2'), + ) + Manufacturer.objects.bulk_create(manufacturers) + + devicetypes = ( + DeviceType(manufacturer=manufacturers[0], model='Device Type 1', slug='device-type-1'), + DeviceType(manufacturer=manufacturers[0], model='Device Type 2', slug='device-type-2'), + ) + DeviceType.objects.bulk_create(devicetypes) + + inventory_item_templates = ( + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 1', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 2', manufacturer=manufacturers[0]), + InventoryItemTemplate(device_type=devicetypes[0], name='Inventory Item Template 3', manufacturer=manufacturers[0]), + ) + for item in inventory_item_templates: + item.save() + + cls.form_data = { + 'device_type': devicetypes[1].pk, + 'name': 'Inventory Item Template X', + 'manufacturer': manufacturers[1].pk, + } + + cls.bulk_create_data = { + 'device_type': devicetypes[1].pk, + 'name_pattern': 'Inventory Item Template [4-6]', + 'manufacturer': manufacturers[1].pk, + } + + cls.bulk_edit_data = { + 'description': 'Foo bar', + } + + class DeviceRoleTestCase(ViewTestCases.OrganizationalObjectViewTestCase): model = DeviceRole diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index d45ce75770..bfd6fecad9 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -115,6 +115,7 @@ path('device-types//rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'), path('device-types//module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'), path('device-types//device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'), + path('device-types//inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'), path('device-types//edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'), path('device-types//delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'), path('device-types//changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}), @@ -203,7 +204,7 @@ path('device-bay-templates//edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'), path('device-bay-templates//delete/', views.DeviceBayTemplateDeleteView.as_view(), name='devicebaytemplate_delete'), - # Device bay templates + # Module bay templates path('module-bay-templates/add/', views.ModuleBayTemplateCreateView.as_view(), name='modulebaytemplate_add'), path('module-bay-templates/edit/', views.ModuleBayTemplateBulkEditView.as_view(), name='modulebaytemplate_bulk_edit'), path('module-bay-templates/rename/', views.ModuleBayTemplateBulkRenameView.as_view(), name='modulebaytemplate_bulk_rename'), @@ -211,6 +212,14 @@ path('module-bay-templates//edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'), path('module-bay-templates//delete/', views.ModuleBayTemplateDeleteView.as_view(), name='modulebaytemplate_delete'), + # Inventory item templates + path('inventory-item-templates/add/', views.InventoryItemTemplateCreateView.as_view(), name='inventoryitemtemplate_add'), + path('inventory-item-templates/edit/', views.InventoryItemTemplateBulkEditView.as_view(), name='inventoryitemtemplate_bulk_edit'), + path('inventory-item-templates/rename/', views.InventoryItemTemplateBulkRenameView.as_view(), name='inventoryitemtemplate_bulk_rename'), + path('inventory-item-templates/delete/', views.InventoryItemTemplateBulkDeleteView.as_view(), name='inventoryitemtemplate_bulk_delete'), + path('inventory-item-templates//edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'), + path('inventory-item-templates//delete/', views.InventoryItemTemplateDeleteView.as_view(), name='inventoryitemtemplate_delete'), + # Device roles path('device-roles/', views.DeviceRoleListView.as_view(), name='devicerole_list'), path('device-roles/add/', views.DeviceRoleEditView.as_view(), name='devicerole_add'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 4e63c0e76c..e641245395 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -869,6 +869,13 @@ class DeviceTypeDeviceBaysView(DeviceTypeComponentsView): viewname = 'dcim:devicetype_devicebays' +class DeviceTypeInventoryItemsView(DeviceTypeComponentsView): + child_model = InventoryItemTemplate + table = tables.InventoryItemTemplateTable + filterset = filtersets.InventoryItemTemplateFilterSet + viewname = 'dcim:devicetype_inventoryitems' + + class DeviceTypeEditView(generic.ObjectEditView): queryset = DeviceType.objects.all() model_form = forms.DeviceTypeForm @@ -890,6 +897,7 @@ class DeviceTypeImportView(generic.ObjectImportView): 'dcim.add_rearporttemplate', 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', + 'dcim.add_inventoryitemtemplate', ] queryset = DeviceType.objects.all() model_form = forms.DeviceTypeImportForm @@ -903,6 +911,7 @@ class DeviceTypeImportView(generic.ObjectImportView): ('front-ports', forms.FrontPortTemplateImportForm), ('module-bays', forms.ModuleBayTemplateImportForm), ('device-bays', forms.DeviceBayTemplateImportForm), + ('inventory-items', forms.InventoryItemTemplateImportForm), )) def prep_related_object_data(self, parent, data): @@ -1362,6 +1371,52 @@ class DeviceBayTemplateBulkDeleteView(generic.BulkDeleteView): table = tables.DeviceBayTemplateTable +# +# Inventory item templates +# + +class InventoryItemTemplateCreateView(generic.ComponentCreateView): + queryset = InventoryItemTemplate.objects.all() + form = forms.DeviceTypeComponentCreateForm + model_form = forms.InventoryItemTemplateForm + template_name = 'dcim/inventoryitem_create.html' + + def alter_object(self, instance, request): + # Set component (if any) + component_type = request.GET.get('component_type') + component_id = request.GET.get('component_id') + + if component_type and component_id: + content_type = get_object_or_404(ContentType, pk=component_type) + instance.component = get_object_or_404(content_type.model_class(), pk=component_id) + + return instance + + +class InventoryItemTemplateEditView(generic.ObjectEditView): + queryset = InventoryItemTemplate.objects.all() + model_form = forms.InventoryItemTemplateForm + + +class InventoryItemTemplateDeleteView(generic.ObjectDeleteView): + queryset = InventoryItemTemplate.objects.all() + + +class InventoryItemTemplateBulkEditView(generic.BulkEditView): + queryset = InventoryItemTemplate.objects.all() + table = tables.InventoryItemTemplateTable + form = forms.InventoryItemTemplateBulkEditForm + + +class InventoryItemTemplateBulkRenameView(generic.BulkRenameView): + queryset = InventoryItemTemplate.objects.all() + + +class InventoryItemTemplateBulkDeleteView(generic.BulkDeleteView): + queryset = InventoryItemTemplate.objects.all() + table = tables.InventoryItemTemplateTable + + # # Device roles # diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py index 5895a7a6e0..607501a9b3 100644 --- a/netbox/netbox/views/generic/object_views.py +++ b/netbox/netbox/views/generic/object_views.py @@ -737,6 +737,7 @@ def post(self, request): 'return_url': self.get_return_url(request), }) + # TODO: Refactor this method for clarity & better error reporting def validate_form(self, request, form): """ Validate form values and set errors on the form object as they are detected. If @@ -763,17 +764,7 @@ def validate_form(self, request, form): if component_form.is_valid(): new_components.append(component_form) - else: - for field, errors in component_form.errors.as_data().items(): - # Assign errors on the child form's name/label field to name_pattern/label_pattern on the parent form - if field == 'name': - field = 'name_pattern' - elif field == 'label': - field = 'label_pattern' - for e in errors: - form.add_error(field, '{}: {}'.format(name, ', '.join(e))) - - if not form.errors: + if not form.errors and not component_form.errors: try: with transaction.atomic(): # Create the new components diff --git a/netbox/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 9c0b08c190..e2bb72a740 100644 --- a/netbox/templates/dcim/devicetype/base.html +++ b/netbox/templates/dcim/devicetype/base.html @@ -44,6 +44,9 @@ {% if perms.dcim.add_devicebaytemplate %}
  • Device Bays
  • {% endif %} + {% if perms.dcim.add_inventoryitemtemplate %} +
  • Inventory Items
  • + {% endif %} {% endif %} @@ -127,4 +130,12 @@ {% endif %} {% endwith %} + + {% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %} + {% if active_tab == tab_name or inventoryitem_count %} + + {% endif %} + {% endwith %} {% endblock %} diff --git a/netbox/templates/dcim/inventoryitem_create.html b/netbox/templates/dcim/inventoryitem_create.html index ef20a21881..43c5b39fa4 100644 --- a/netbox/templates/dcim/inventoryitem_create.html +++ b/netbox/templates/dcim/inventoryitem_create.html @@ -1,17 +1,17 @@ -{% extends 'dcim/component_create.html' %} +{% extends 'generic/object_edit.html' %} {% load helpers %} +{% load form_helpers %} {% block form %} + {% render_form replication_form %} {% if obj.component %}
    - -
    -
    - {{ obj.component }} + +
    +
    -
    {% endif %} {{ block.super }}