diff --git a/netbox/dcim/api/nested_serializers.py b/netbox/dcim/api/nested_serializers.py index 0cd112a1d..0ec0e07e0 100644 --- a/netbox/dcim/api/nested_serializers.py +++ b/netbox/dcim/api/nested_serializers.py @@ -21,6 +21,7 @@ __all__ = [ 'NestedInterfaceTemplateSerializer', 'NestedInventoryItemSerializer', 'NestedInventoryItemRoleSerializer', + 'NestedInventoryItemTemplateSerializer', 'NestedManufacturerSerializer', 'NestedModuleBaySerializer', 'NestedModuleBayTemplateSerializer', @@ -231,6 +232,15 @@ class NestedDeviceBayTemplateSerializer(WritableNestedSerializer): 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 30f451e84..3bc369a64 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -447,6 +447,40 @@ class DeviceBayTemplateSerializer(ValidatedModelSerializer): 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 be963d36d..c6f48aed3 100644 --- a/netbox/dcim/api/urls.py +++ b/netbox/dcim/api/urls.py @@ -31,6 +31,7 @@ router.register('front-port-templates', views.FrontPortTemplateViewSet) 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 479abf7b2..31c1fd1d0 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 00126ebf8..45844b049 100644 --- a/netbox/dcim/constants.py +++ b/netbox/dcim/constants.py @@ -62,6 +62,18 @@ POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage # 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 14a2ae3ee..9069ab25c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -40,6 +40,7 @@ __all__ = ( 'InterfaceTemplateFilterSet', 'InventoryItemFilterSet', 'InventoryItemRoleFilterSet', + 'InventoryItemTemplateFilterSet', 'LocationFilterSet', 'ManufacturerFilterSet', 'ModuleBayFilterSet', @@ -687,6 +688,49 @@ class DeviceBayTemplateFilterSet(ChangeLoggedModelFilterSet, DeviceTypeComponent 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 93a90a1cb..3cd8ec35e 100644 --- a/netbox/dcim/forms/bulk_edit.py +++ b/netbox/dcim/forms/bulk_edit.py @@ -31,6 +31,7 @@ __all__ = ( 'InterfaceTemplateBulkEditForm', 'InventoryItemBulkEditForm', 'InventoryItemRoleBulkEditForm', + 'InventoryItemTemplateBulkEditForm', 'LocationBulkEditForm', 'ManufacturerBulkEditForm', 'ModuleBulkEditForm', @@ -907,6 +908,31 @@ class DeviceBayTemplateBulkEditForm(BulkEditForm): 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 e2c343028..9fcea7661 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -38,6 +38,7 @@ __all__ = ( 'InterfaceTemplateForm', 'InventoryItemForm', 'InventoryItemRoleForm', + 'InventoryItemTemplateForm', 'LocationForm', 'ManufacturerForm', 'ModuleForm', @@ -1073,6 +1074,48 @@ class DeviceBayTemplateForm(BootstrapMixin, forms.ModelForm): } +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_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 36c6ae8bc..afbcd6543 100644 --- a/netbox/dcim/forms/object_import.py +++ b/netbox/dcim/forms/object_import.py @@ -11,6 +11,7 @@ __all__ = ( 'DeviceTypeImportForm', 'FrontPortTemplateImportForm', 'InterfaceTemplateImportForm', + 'InventoryItemTemplateImportForm', 'ModuleBayTemplateImportForm', 'ModuleTypeImportForm', 'PowerOutletTemplateImportForm', @@ -49,24 +50,7 @@ class ModuleTypeImportForm(BootstrapMixin, forms.ModelForm): # 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 PowerOutletTemplateImportForm(ComponentTemplateImportForm): '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 DeviceBayTemplateImportForm(ComponentTemplateImportForm): 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 8e03ab409..1d5b6a580 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 b2a94c3ed..a47ca40ca 100644 --- a/netbox/dcim/graphql/types.py +++ b/netbox/dcim/graphql/types.py @@ -26,6 +26,7 @@ __all__ = ( 'InterfaceTemplateType', 'InventoryItemType', 'InventoryItemRoleType', + 'InventoryItemTemplateType', 'LocationType', 'ManufacturerType', 'ModuleType', @@ -172,6 +173,14 @@ class DeviceBayTemplateType(ComponentTemplateObjectType): 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 000000000..8c3fe78c3 --- /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 71fed25c5..b3ede8282 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 @@ __all__ = ( '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 ConsolePortTemplate(ModularComponentTemplateModel): ) 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 ConsoleServerPortTemplate(ModularComponentTemplateModel): ) 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 PowerPortTemplate(ModularComponentTemplateModel): ) 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 @@ class PowerOutletTemplate(ModularComponentTemplateModel): 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 InterfaceTemplate(ModularComponentTemplateModel): ) 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 @@ class FrontPortTemplate(ModularComponentTemplateModel): 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 RearPortTemplate(ModularComponentTemplateModel): ) 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 @@ class DeviceBayTemplate(ComponentTemplateModel): 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 8d0a7ae19..631f0c8c1 100644 --- a/netbox/dcim/models/devices.py +++ b/netbox/dcim/models/devices.py @@ -933,6 +933,9 @@ class Device(PrimaryModel, ConfigContextModel): 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 ad4c4d844..885fe69a4 100644 --- a/netbox/dcim/tables/devicetypes.py +++ b/netbox/dcim/tables/devicetypes.py @@ -1,8 +1,9 @@ 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, @@ -15,6 +16,7 @@ __all__ = ( 'DeviceTypeTable', 'FrontPortTemplateTable', 'InterfaceTemplateTable', + 'InventoryItemTemplateTable', 'ManufacturerTable', 'ModuleBayTemplateTable', 'PowerOutletTemplateTable', @@ -223,3 +225,25 @@ class DeviceBayTemplateTable(ComponentTemplateTable): 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/tests/test_api.py b/netbox/dcim/tests/test_api.py index b3c41e277..0c9b918df 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -897,6 +897,57 @@ class DeviceBayTemplateTest(APIViewTestCases.APIViewTestCase): ] +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 f53705336..2973e46e7 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1214,6 +1214,86 @@ class DeviceBayTemplateTestCase(TestCase, ChangeLoggedFilterSetTests): 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 8f077df92..8f7cb606b 100644 --- a/netbox/dcim/tests/test_views.py +++ b/netbox/dcim/tests/test_views.py @@ -580,6 +580,20 @@ class DeviceTypeTestCase( 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 @@ device-bays: - 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 @@ device-bays: 'dcim.add_rearporttemplate', 'dcim.add_modulebaytemplate', 'dcim.add_devicebaytemplate', + 'dcim.add_inventoryitemtemplate', ) form_data = { @@ -729,13 +751,17 @@ device-bays: 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 @@ class DeviceBayTemplateTestCase(ViewTestCases.DeviceComponentTemplateViewTestCas } +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 d45ce7577..bfd6fecad 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -115,6 +115,7 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 @@ urlpatterns = [ 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 4e63c0e76..54d100f41 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,51 @@ 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 + + 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/templates/dcim/devicetype/base.html b/netbox/templates/dcim/devicetype/base.html index 9c0b08c19..e2bb72a74 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 %}