Merge pull request #8186 from netbox-community/8118-inventoryitem-template

Closes #8118: Inventory item templates
This commit is contained in:
Jeremy Stretch 2021-12-29 16:58:57 -05:00 committed by GitHub
commit ae3c871438
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
28 changed files with 766 additions and 63 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 @@ __all__ = (
'DeviceTypeTable',
'FrontPortTemplateTable',
'InterfaceTemplateTable',
'InventoryItemTemplateTable',
'ManufacturerTable',
'ModuleBayTemplateTable',
'PowerOutletTemplateTable',
@ -112,7 +115,8 @@ class ComponentTemplateTable(BaseTable):
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 ConsolePortTemplateTable(ComponentTemplateTable):
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 ConsoleServerPortTemplateTable(ComponentTemplateTable):
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 PowerPortTemplateTable(ComponentTemplateTable):
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 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"

View File

@ -93,6 +93,19 @@ LOCATION_ELEVATIONS = """
</a>
"""
#
# Device component templatebuttons
#
MODULAR_COMPONENT_TEMPLATE_BUTTONS = """
{% load helpers %}
{% if perms.dcim.add_invnetoryitemtemplate %}
<a href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ record.device_type.pk }}&component_type={{ record|content_type_id }}&component_id={{ record.pk }}&return_url={{ request.path }}" title="Add inventory item" class="btn btn-primary btn-sm">
<i class="mdi mdi-plus-thick" aria-hidden="true"></i>
</a>
{% endif %}
"""
#
# Device component buttons
#

View File

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

View File

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

View File

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

View File

@ -115,6 +115,7 @@ urlpatterns = [
path('device-types/<int:pk>/rear-ports/', views.DeviceTypeRearPortsView.as_view(), name='devicetype_rearports'),
path('device-types/<int:pk>/module-bays/', views.DeviceTypeModuleBaysView.as_view(), name='devicetype_modulebays'),
path('device-types/<int:pk>/device-bays/', views.DeviceTypeDeviceBaysView.as_view(), name='devicetype_devicebays'),
path('device-types/<int:pk>/inventory-items/', views.DeviceTypeInventoryItemsView.as_view(), name='devicetype_inventoryitems'),
path('device-types/<int:pk>/edit/', views.DeviceTypeEditView.as_view(), name='devicetype_edit'),
path('device-types/<int:pk>/delete/', views.DeviceTypeDeleteView.as_view(), name='devicetype_delete'),
path('device-types/<int:pk>/changelog/', ObjectChangeLogView.as_view(), name='devicetype_changelog', kwargs={'model': DeviceType}),
@ -203,7 +204,7 @@ urlpatterns = [
path('device-bay-templates/<int:pk>/edit/', views.DeviceBayTemplateEditView.as_view(), name='devicebaytemplate_edit'),
path('device-bay-templates/<int:pk>/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/<int:pk>/edit/', views.ModuleBayTemplateEditView.as_view(), name='modulebaytemplate_edit'),
path('module-bay-templates/<int:pk>/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/<int:pk>/edit/', views.InventoryItemTemplateEditView.as_view(), name='inventoryitemtemplate_edit'),
path('inventory-item-templates/<int:pk>/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'),

View File

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

View File

@ -737,6 +737,7 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
'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 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
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

View File

@ -44,6 +44,9 @@
{% if perms.dcim.add_devicebaytemplate %}
<li><a class="dropdown-item" href="{% url 'dcim:devicebaytemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_devicebays' pk=object.pk %}">Device Bays</a></li>
{% endif %}
{% if perms.dcim.add_inventoryitemtemplate %}
<li><a class="dropdown-item" href="{% url 'dcim:inventoryitemtemplate_add' %}?device_type={{ object.pk }}&return_url={% url 'dcim:devicetype_inventoryitems' pk=object.pk %}">Inventory Items</a></li>
{% endif %}
</ul>
</div>
{% endif %}
@ -127,4 +130,12 @@
</li>
{% endif %}
{% endwith %}
{% with tab_name='inventory-item-templates' inventoryitem_count=object.inventoryitemtemplates.count %}
{% if active_tab == tab_name or inventoryitem_count %}
<li role="presentation" class="nav-item">
<a class="nav-link {% if active_tab == tab_name %} active{% endif %}" href="{% url 'dcim:devicetype_inventoryitems' pk=object.pk %}">Inventory Items {% badge inventoryitem_count %}</a>
</li>
{% endif %}
{% endwith %}
{% endblock %}

View File

@ -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 %}
<div class="row mb-3">
<label class="col-sm-3 col-form-label text-lg-end required">
{{ obj.component|meta:"verbose_name"|bettertitle }}
</label>
<div class="col-sm-9">
<div class="form-control-plaintext">
<a href="{{ obj.component.get_absolute_url }}" class="">{{ obj.component }}</a>
<label class="col-sm-3 col-form-label text-lg-end">
{{ obj.component|meta:"verbose_name"|bettertitle }}
</label>
<div class="col">
<input class="form-control" value="{{ obj.component }}" disabled />
</div>
</div>
</div>
{% endif %}
{{ block.super }}