diff --git a/docs/release-notes/version-3.2.md b/docs/release-notes/version-3.2.md
index 608a436a1..e0db0b13b 100644
--- a/docs/release-notes/version-3.2.md
+++ b/docs/release-notes/version-3.2.md
@@ -48,6 +48,7 @@ FIELD_CHOICES = {
* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks
* [#7759](https://github.com/netbox-community/netbox/issues/7759) - Improved the user preferences form
* [#7784](https://github.com/netbox-community/netbox/issues/7784) - Support cluster type assignment for config contexts
+* [#7846](https://github.com/netbox-community/netbox/issues/7846) - Enable associating inventory items with device components
* [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
### Other Changes
@@ -76,7 +77,8 @@ FIELD_CHOICES = {
* dcim.Interface
* Added `module` field
* dcim.InventoryItem
- * Added `role` field
+ * Added `component_type`, `component_id`, and `role` fields
+ * Added read-only `component` field
* dcim.PowerPort
* Added `module` field
* dcim.PowerOutlet
diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py
index 5e07ea3fd..30f451e84 100644
--- a/netbox/dcim/api/serializers.py
+++ b/netbox/dcim/api/serializers.py
@@ -810,17 +810,32 @@ class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer()
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None)
- manufacturer = NestedManufacturerSerializer(required=False, 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_MODELS),
+ required=False,
+ allow_null=True
+ )
+ component = serializers.SerializerMethodField(read_only=True)
_depth = serializers.IntegerField(source='level', read_only=True)
class Meta:
model = InventoryItem
fields = [
'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial',
- 'asset_tag', 'discovered', 'description', 'tags', 'custom_fields', 'created', 'last_updated', '_depth',
+ 'asset_tag', 'discovered', 'description', 'component_type', 'component_id', 'component', 'tags',
+ 'custom_fields', '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
+
#
# Device component roles
diff --git a/netbox/dcim/constants.py b/netbox/dcim/constants.py
index 2136f06aa..00126ebf8 100644
--- a/netbox/dcim/constants.py
+++ b/netbox/dcim/constants.py
@@ -50,16 +50,31 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
#
-# PowerFeeds
+# Power feeds
#
POWERFEED_VOLTAGE_DEFAULT = 120
-
POWERFEED_AMPERAGE_DEFAULT = 20
-
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage
+#
+# Device components
+#
+
+MODULAR_COMPONENT_MODELS = Q(
+ app_label='dcim',
+ model__in=(
+ 'consoleport',
+ 'consoleserverport',
+ 'frontport',
+ 'interface',
+ 'poweroutlet',
+ 'powerport',
+ 'rearport',
+ ))
+
+
#
# Cabling and connections
#
diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py
index 01c0a278d..14a2ae3ee 100644
--- a/netbox/dcim/filtersets.py
+++ b/netbox/dcim/filtersets.py
@@ -1294,6 +1294,8 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
to_field_name='slug',
label='Role (slug)',
)
+ component_type = ContentTypeFilter()
+ component_id = MultiValueNumberFilter()
serial = django_filters.CharFilter(
lookup_expr='iexact'
)
diff --git a/netbox/dcim/forms/bulk_create.py b/netbox/dcim/forms/bulk_create.py
index e78e0ee19..02c8feb4b 100644
--- a/netbox/dcim/forms/bulk_create.py
+++ b/netbox/dcim/forms/bulk_create.py
@@ -4,7 +4,7 @@ from dcim.models import *
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
-from .object_create import ComponentForm
+from .object_create import ComponentCreateForm
__all__ = (
'ConsolePortBulkCreateForm',
@@ -24,7 +24,7 @@ __all__ = (
# Device components
#
-class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm):
+class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput()
diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py
index 2be571f71..6db3e2634 100644
--- a/netbox/dcim/forms/models.py
+++ b/netbox/dcim/forms/models.py
@@ -12,8 +12,8 @@ from extras.models import Tag
from ipam.models import IPAddress, VLAN, VLANGroup, ASN
from tenancy.forms import TenancyForm
from utilities.forms import (
- APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField,
- DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
+ APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
+ DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
SlugField, StaticSelect,
)
from virtualization.models import Cluster, ClusterGroup
@@ -957,6 +957,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
+ 'type': StaticSelect,
}
@@ -969,6 +970,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
+ 'type': StaticSelect,
}
@@ -981,10 +983,19 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
+ 'type': StaticSelect(),
}
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
+ power_port = DynamicModelChoiceField(
+ queryset=PowerPortTemplate.objects.all(),
+ required=False,
+ query_params={
+ 'devicetype_id': '$device_type',
+ }
+ )
+
class Meta:
model = PowerOutletTemplate
fields = [
@@ -993,18 +1004,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
+ 'type': StaticSelect(),
+ 'feed_leg': StaticSelect(),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit power_port choices to current DeviceType/ModuleType
- if self.instance.pk:
- self.fields['power_port'].queryset = PowerPortTemplate.objects.filter(
- device_type=self.instance.device_type,
- module_type=self.instance.module_type
- )
-
class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
@@ -1020,6 +1023,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
+ rear_port = DynamicModelChoiceField(
+ queryset=RearPortTemplate.objects.all(),
+ required=False,
+ query_params={
+ 'devicetype_id': '$device_type',
+ }
+ )
+
class Meta:
model = FrontPortTemplate
fields = [
@@ -1029,19 +1040,9 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = {
'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(),
- 'rear_port': StaticSelect(),
+ 'type': StaticSelect(),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit rear_port choices to current DeviceType/ModuleType
- if self.instance.pk:
- self.fields['rear_port'].queryset = RearPortTemplate.objects.filter(
- device_type=self.instance.device_type,
- module_type=self.instance.module_type
- )
-
class RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta:
@@ -1095,6 +1096,8 @@ class ConsolePortForm(CustomFieldModelForm):
]
widgets = {
'device': forms.HiddenInput(),
+ 'type': StaticSelect(),
+ 'speed': StaticSelect(),
}
@@ -1111,6 +1114,8 @@ class ConsoleServerPortForm(CustomFieldModelForm):
]
widgets = {
'device': forms.HiddenInput(),
+ 'type': StaticSelect(),
+ 'speed': StaticSelect(),
}
@@ -1128,13 +1133,17 @@ class PowerPortForm(CustomFieldModelForm):
]
widgets = {
'device': forms.HiddenInput(),
+ 'type': StaticSelect(),
}
class PowerOutletForm(CustomFieldModelForm):
- power_port = forms.ModelChoiceField(
+ power_port = DynamicModelChoiceField(
queryset=PowerPort.objects.all(),
- required=False
+ required=False,
+ query_params={
+ 'device_id': '$device',
+ }
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
@@ -1148,34 +1157,34 @@ class PowerOutletForm(CustomFieldModelForm):
]
widgets = {
'device': forms.HiddenInput(),
+ 'type': StaticSelect(),
+ 'feed_leg': StaticSelect(),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit power_port choices to the local device
- if hasattr(self.instance, 'device'):
- self.fields['power_port'].queryset = PowerPort.objects.filter(
- device=self.instance.device
- )
-
class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='Parent interface'
+ label='Parent interface',
+ query_params={
+ 'device_id': '$device',
+ }
)
bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
- label='Bridged interface'
+ label='Bridged interface',
+ query_params={
+ 'device_id': '$device',
+ }
)
lag = DynamicModelChoiceField(
queryset=Interface.objects.all(),
required=False,
label='LAG interface',
query_params={
+ 'device_id': '$device',
'type': 'lag',
}
)
@@ -1203,6 +1212,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Untagged VLAN',
query_params={
'group_id': '$vlan_group',
+ 'available_on_device': '$device',
}
)
tagged_vlans = DynamicModelMultipleChoiceField(
@@ -1211,6 +1221,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Tagged VLANs',
query_params={
'group_id': '$vlan_group',
+ 'available_on_device': '$device',
}
)
tags = DynamicModelMultipleChoiceField(
@@ -1225,6 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency',
'rf_channel_width', 'tx_power', 'wireless_lans', 'untagged_vlan', 'tagged_vlans', 'tags',
]
+ fieldsets = (
+ ('Interface', ('device', 'name', 'type', 'label', 'description', 'tags')),
+ ('Addressing', ('mac_address', 'wwn')),
+ ('Operation', ('mtu', 'tx_power', 'enabled', 'mgmt_only', 'mark_connected')),
+ ('Related Interfaces', ('parent', 'bridge', 'lag')),
+ ('802.1Q Switching', ('mode', 'vlan_group', 'untagged_vlan', 'tagged_vlans')),
+ ('Wireless', (
+ 'rf_role', 'rf_channel', 'rf_channel_frequency', 'rf_channel_width', 'wireless_lan_group',
+ 'wireless_lans',
+ )),
+ )
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
@@ -1241,26 +1263,14 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'rf_channel_width': "Populated by selected channel (if set)",
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- device = Device.objects.get(pk=self.data['device']) if self.is_bound else self.instance.device
-
- # Restrict parent/bridge/LAG interface assignment by device/VC
- self.fields['parent'].widget.add_query_param('device_id', device.pk)
- self.fields['bridge'].widget.add_query_param('device_id', device.pk)
- self.fields['lag'].widget.add_query_param('device_id', device.pk)
- if device.virtual_chassis and device.virtual_chassis.master:
- self.fields['parent'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
- self.fields['bridge'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
- self.fields['lag'].widget.add_query_param('device_id', device.virtual_chassis.master.pk)
-
- # Limit VLAN choices by device
- self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device.pk)
- self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device.pk)
-
class FrontPortForm(CustomFieldModelForm):
+ rear_port = DynamicModelChoiceField(
+ queryset=RearPort.objects.all(),
+ query_params={
+ 'device_id': '$device',
+ }
+ )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1275,18 +1285,8 @@ class FrontPortForm(CustomFieldModelForm):
widgets = {
'device': forms.HiddenInput(),
'type': StaticSelect(),
- 'rear_port': StaticSelect(),
}
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit RearPort choices to the local device
- if hasattr(self.instance, 'device'):
- self.fields['rear_port'].queryset = self.fields['rear_port'].queryset.filter(
- device=self.instance.device
- )
-
class RearPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField(
@@ -1358,9 +1358,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class InventoryItemForm(CustomFieldModelForm):
- device = DynamicModelChoiceField(
- queryset=Device.objects.all()
- )
parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(),
required=False,
@@ -1376,6 +1373,15 @@ class InventoryItemForm(CustomFieldModelForm):
queryset=Manufacturer.objects.all(),
required=False
)
+ component_type = ContentTypeChoiceField(
+ queryset=ContentType.objects.all(),
+ limit_choices_to=MODULAR_COMPONENT_MODELS,
+ required=False,
+ widget=StaticSelect
+ )
+ component_id = forms.IntegerField(
+ required=False
+ )
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
@@ -1385,8 +1391,16 @@ class InventoryItemForm(CustomFieldModelForm):
model = InventoryItem
fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
- 'description', 'tags',
+ 'description', 'component_type', 'component_id', 'tags',
]
+ fieldsets = (
+ ('Inventory Item', ('device', 'parent', 'name', 'label', 'role', 'description', 'tags')),
+ ('Hardware', ('manufacturer', 'part_id', 'serial', 'asset_tag')),
+ ('Component', ('component_type', 'component_id')),
+ )
+ widgets = {
+ 'device': forms.HiddenInput(),
+ }
#
diff --git a/netbox/dcim/forms/object_create.py b/netbox/dcim/forms/object_create.py
index 9e208300b..5e8daf38d 100644
--- a/netbox/dcim/forms/object_create.py
+++ b/netbox/dcim/forms/object_create.py
@@ -1,43 +1,21 @@
from django import forms
-from dcim.choices import *
-from dcim.constants import *
from dcim.models import *
-from extras.forms import CustomFieldModelForm, CustomFieldsMixin
+from extras.forms import CustomFieldModelForm
from extras.models import Tag
-from ipam.models import VLAN
from utilities.forms import (
- add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField,
- ExpandableNameField, StaticSelect,
+ BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
)
-from wireless.choices import *
-from .common import InterfaceCommonForm
__all__ = (
- 'ConsolePortCreateForm',
- 'ConsolePortTemplateCreateForm',
- 'ConsoleServerPortCreateForm',
- 'ConsoleServerPortTemplateCreateForm',
- 'DeviceBayCreateForm',
- 'DeviceBayTemplateCreateForm',
+ 'ComponentCreateForm',
'FrontPortCreateForm',
'FrontPortTemplateCreateForm',
- 'InterfaceCreateForm',
- 'InterfaceTemplateCreateForm',
- 'InventoryItemCreateForm',
- 'ModuleBayCreateForm',
- 'ModuleBayTemplateCreateForm',
- 'PowerOutletCreateForm',
- 'PowerOutletTemplateCreateForm',
- 'PowerPortCreateForm',
- 'PowerPortTemplateCreateForm',
- 'RearPortCreateForm',
- 'RearPortTemplateCreateForm',
'VirtualChassisCreateForm',
)
-class ComponentForm(BootstrapMixin, forms.Form):
+class ComponentCreateForm(BootstrapMixin, forms.Form):
"""
Subclass this form when facilitating the creation of one or more device component or component templates based on
a name pattern.
@@ -65,6 +43,97 @@ class ComponentForm(BootstrapMixin, forms.Form):
}, code='label_pattern_mismatch')
+class FrontPortTemplateCreateForm(ComponentCreateForm):
+ rear_port_set = forms.MultipleChoiceField(
+ choices=[],
+ label='Rear ports',
+ help_text='Select one rear port assignment for each front port being created.',
+ )
+ field_order = (
+ 'name_pattern', 'label_pattern', 'rear_port_set',
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ device_type = DeviceType.objects.get(
+ pk=self.initial.get('device_type') or self.data.get('device_type')
+ )
+
+ # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
+ occupied_port_positions = [
+ (front_port.rear_port_id, front_port.rear_port_position)
+ for front_port in device_type.frontporttemplates.all()
+ ]
+
+ # Populate rear port choices
+ choices = []
+ rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
+ for rear_port in rear_ports:
+ for i in range(1, rear_port.positions + 1):
+ if (rear_port.pk, i) not in occupied_port_positions:
+ choices.append(
+ ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+ )
+ self.fields['rear_port_set'].choices = choices
+
+ def get_iterative_data(self, iteration):
+
+ # Assign rear port and position from selected set
+ rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+ return {
+ 'rear_port': int(rear_port),
+ 'rear_port_position': int(position),
+ }
+
+
+class FrontPortCreateForm(ComponentCreateForm):
+ rear_port_set = forms.MultipleChoiceField(
+ choices=[],
+ label='Rear ports',
+ help_text='Select one rear port assignment for each front port being created.',
+ )
+ field_order = (
+ 'name_pattern', 'label_pattern', 'rear_port_set',
+ )
+
+ def __init__(self, *args, **kwargs):
+ super().__init__(*args, **kwargs)
+
+ device = Device.objects.get(
+ pk=self.initial.get('device') or self.data.get('device')
+ )
+
+ # Determine which rear port positions are occupied. These will be excluded from the list of available
+ # mappings.
+ occupied_port_positions = [
+ (front_port.rear_port_id, front_port.rear_port_position)
+ for front_port in device.frontports.all()
+ ]
+
+ # Populate rear port choices
+ choices = []
+ rear_ports = RearPort.objects.filter(device=device)
+ for rear_port in rear_ports:
+ for i in range(1, rear_port.positions + 1):
+ if (rear_port.pk, i) not in occupied_port_positions:
+ choices.append(
+ ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
+ )
+ self.fields['rear_port_set'].choices = choices
+
+ def get_iterative_data(self, iteration):
+
+ # Assign rear port and position from selected set
+ rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
+
+ return {
+ 'rear_port': int(rear_port),
+ 'rear_port_position': int(position),
+ }
+
+
class VirtualChassisCreateForm(CustomFieldModelForm):
region = DynamicModelChoiceField(
queryset=Region.objects.all(),
@@ -138,549 +207,3 @@ class VirtualChassisCreateForm(CustomFieldModelForm):
member.save()
return instance
-
-
-#
-# Component templates
-#
-
-class ComponentTemplateCreateForm(ComponentForm):
- """
- Base form for the creation of device component templates (subclassed from ComponentTemplateModel).
- """
- manufacturer = DynamicModelChoiceField(
- queryset=Manufacturer.objects.all(),
- required=False,
- initial_params={
- 'device_types': 'device_type',
- 'module_types': 'module_type',
- }
- )
- device_type = DynamicModelChoiceField(
- queryset=DeviceType.objects.all(),
- required=False,
- query_params={
- 'manufacturer_id': '$manufacturer'
- }
- )
- description = forms.CharField(
- required=False
- )
-
-
-class ModularComponentTemplateCreateForm(ComponentTemplateCreateForm):
- module_type = DynamicModelChoiceField(
- queryset=ModuleType.objects.all(),
- required=False,
- query_params={
- 'manufacturer_id': '$manufacturer'
- }
- )
-
-
-class ConsolePortTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortTypeChoices),
- widget=StaticSelect()
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
- )
-
-
-class ConsoleServerPortTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortTypeChoices),
- widget=StaticSelect()
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'description',
- )
-
-
-class PowerPortTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=add_blank_choice(PowerPortTypeChoices),
- required=False
- )
- maximum_draw = forms.IntegerField(
- min_value=1,
- required=False,
- help_text="Maximum power draw (watts)"
- )
- allocated_draw = forms.IntegerField(
- min_value=1,
- required=False,
- help_text="Allocated power draw (watts)"
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'maximum_draw',
- 'allocated_draw', 'description',
- )
-
-
-class PowerOutletTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=add_blank_choice(PowerOutletTypeChoices),
- required=False
- )
- power_port = DynamicModelChoiceField(
- queryset=PowerPortTemplate.objects.all(),
- required=False,
- query_params={
- 'devicetype_id': '$device_type',
- 'moduletype_id': '$module_type',
- }
- )
- feed_leg = forms.ChoiceField(
- choices=add_blank_choice(PowerOutletFeedLegChoices),
- required=False,
- widget=StaticSelect()
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg',
- 'description',
- )
-
-
-class InterfaceTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=InterfaceTypeChoices,
- widget=StaticSelect()
- )
- mgmt_only = forms.BooleanField(
- required=False,
- label='Management only'
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'mgmt_only',
- 'description',
- )
-
-
-class FrontPortTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=PortTypeChoices,
- widget=StaticSelect()
- )
- color = ColorField(
- required=False
- )
- rear_port_set = forms.MultipleChoiceField(
- choices=[],
- label='Rear ports',
- help_text='Select one rear port assignment for each front port being created.',
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set',
- 'description',
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- device_type = DeviceType.objects.get(
- pk=self.initial.get('device_type') or self.data.get('device_type')
- )
-
- # Determine which rear port positions are occupied. These will be excluded from the list of available mappings.
- occupied_port_positions = [
- (front_port.rear_port_id, front_port.rear_port_position)
- for front_port in device_type.frontporttemplates.all()
- ]
-
- # Populate rear port choices
- choices = []
- rear_ports = RearPortTemplate.objects.filter(device_type=device_type)
- for rear_port in rear_ports:
- for i in range(1, rear_port.positions + 1):
- if (rear_port.pk, i) not in occupied_port_positions:
- choices.append(
- ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
- )
- self.fields['rear_port_set'].choices = choices
-
- def clean(self):
- super().clean()
-
- # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
- front_port_count = len(self.cleaned_data['name_pattern'])
- rear_port_count = len(self.cleaned_data['rear_port_set'])
- if front_port_count != rear_port_count:
- raise forms.ValidationError({
- 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
- 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
- })
-
- def get_iterative_data(self, iteration):
-
- # Assign rear port and position from selected set
- rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
-
- return {
- 'rear_port': int(rear_port),
- 'rear_port_position': int(position),
- }
-
-
-class RearPortTemplateCreateForm(ModularComponentTemplateCreateForm):
- type = forms.ChoiceField(
- choices=PortTypeChoices,
- widget=StaticSelect(),
- )
- color = ColorField(
- required=False
- )
- positions = forms.IntegerField(
- min_value=REARPORT_POSITIONS_MIN,
- max_value=REARPORT_POSITIONS_MAX,
- initial=1,
- help_text='The number of front ports which may be mapped to each rear port'
- )
- field_order = (
- 'manufacturer', 'device_type', 'module_type', 'name_pattern', 'label_pattern', 'type', 'color', 'positions',
- 'description',
- )
-
-
-class ModuleBayTemplateCreateForm(ComponentTemplateCreateForm):
- # TODO: Support patterned position assignment
- field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-
-
-class DeviceBayTemplateCreateForm(ComponentTemplateCreateForm):
- field_order = ('manufacturer', 'device_type', 'name_pattern', 'label_pattern', 'description')
-
-
-#
-# Device components
-#
-
-class ComponentCreateForm(CustomFieldsMixin, ComponentForm):
- """
- Base form for the creation of device components (models subclassed from ComponentModel).
- """
- device = DynamicModelChoiceField(
- queryset=Device.objects.all()
- )
- description = forms.CharField(
- max_length=200,
- required=False
- )
- tags = DynamicModelMultipleChoiceField(
- queryset=Tag.objects.all(),
- required=False
- )
-
-
-class ConsolePortCreateForm(ComponentCreateForm):
- model = ConsolePort
- type = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortTypeChoices),
- required=False,
- widget=StaticSelect()
- )
- speed = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortSpeedChoices),
- required=False,
- widget=StaticSelect()
- )
- field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class ConsoleServerPortCreateForm(ComponentCreateForm):
- model = ConsoleServerPort
- type = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortTypeChoices),
- required=False,
- widget=StaticSelect()
- )
- speed = forms.ChoiceField(
- choices=add_blank_choice(ConsolePortSpeedChoices),
- required=False,
- widget=StaticSelect()
- )
- field_order = ('device', 'name_pattern', 'label_pattern', 'type', 'speed', 'mark_connected', 'description', 'tags')
-
-
-class PowerPortCreateForm(ComponentCreateForm):
- model = PowerPort
- type = forms.ChoiceField(
- choices=add_blank_choice(PowerPortTypeChoices),
- required=False,
- widget=StaticSelect()
- )
- maximum_draw = forms.IntegerField(
- min_value=1,
- required=False,
- help_text="Maximum draw in watts"
- )
- allocated_draw = forms.IntegerField(
- min_value=1,
- required=False,
- help_text="Allocated draw in watts"
- )
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'maximum_draw', 'allocated_draw', 'mark_connected',
- 'description', 'tags',
- )
-
-
-class PowerOutletCreateForm(ComponentCreateForm):
- model = PowerOutlet
- type = forms.ChoiceField(
- choices=add_blank_choice(PowerOutletTypeChoices),
- required=False,
- widget=StaticSelect()
- )
- power_port = forms.ModelChoiceField(
- queryset=PowerPort.objects.all(),
- required=False
- )
- feed_leg = forms.ChoiceField(
- choices=add_blank_choice(PowerOutletFeedLegChoices),
- required=False
- )
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'power_port', 'feed_leg', 'mark_connected', 'description',
- 'tags',
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit power_port queryset to PowerPorts which belong to the parent Device
- device = Device.objects.get(
- pk=self.initial.get('device') or self.data.get('device')
- )
- self.fields['power_port'].queryset = PowerPort.objects.filter(device=device)
-
-
-class InterfaceCreateForm(ComponentCreateForm, InterfaceCommonForm):
- model = Interface
- type = forms.ChoiceField(
- choices=InterfaceTypeChoices,
- widget=StaticSelect(),
- )
- enabled = forms.BooleanField(
- required=False,
- initial=True
- )
- parent = DynamicModelChoiceField(
- queryset=Interface.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
- )
- bridge = DynamicModelChoiceField(
- queryset=Interface.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- }
- )
- lag = DynamicModelChoiceField(
- queryset=Interface.objects.all(),
- required=False,
- query_params={
- 'device_id': '$device',
- 'type': 'lag',
- },
- label='LAG'
- )
- mac_address = forms.CharField(
- required=False,
- label='MAC Address'
- )
- wwn = forms.CharField(
- required=False,
- label='WWN'
- )
- mgmt_only = forms.BooleanField(
- required=False,
- label='Management only',
- help_text='This interface is used only for out-of-band management'
- )
- mode = forms.ChoiceField(
- choices=add_blank_choice(InterfaceModeChoices),
- required=False,
- widget=StaticSelect()
- )
- rf_role = forms.ChoiceField(
- choices=add_blank_choice(WirelessRoleChoices),
- required=False,
- widget=StaticSelect(),
- label='Wireless role'
- )
- rf_channel = forms.ChoiceField(
- choices=add_blank_choice(WirelessChannelChoices),
- required=False,
- widget=StaticSelect(),
- label='Wireless channel'
- )
- rf_channel_frequency = forms.DecimalField(
- required=False,
- label='Channel frequency (MHz)'
- )
- rf_channel_width = forms.DecimalField(
- required=False,
- label='Channel width (MHz)'
- )
- untagged_vlan = DynamicModelChoiceField(
- queryset=VLAN.objects.all(),
- required=False,
- label='Untagged VLAN'
- )
- tagged_vlans = DynamicModelMultipleChoiceField(
- queryset=VLAN.objects.all(),
- required=False,
- label='Tagged VLANs'
- )
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'enabled', 'parent', 'bridge', 'lag', 'mtu', 'mac_address',
- 'wwn', 'description', 'mgmt_only', 'mark_connected', 'rf_role', 'rf_channel', 'rf_channel_frequency',
- 'rf_channel_width', 'mode', 'untagged_vlan', 'tagged_vlans', 'tags'
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- # Limit VLAN choices by device
- device_id = self.initial.get('device') or self.data.get('device')
- self.fields['untagged_vlan'].widget.add_query_param('available_on_device', device_id)
- self.fields['tagged_vlans'].widget.add_query_param('available_on_device', device_id)
-
-
-class FrontPortCreateForm(ComponentCreateForm):
- model = FrontPort
- type = forms.ChoiceField(
- choices=PortTypeChoices,
- widget=StaticSelect(),
- )
- color = ColorField(
- required=False
- )
- rear_port_set = forms.MultipleChoiceField(
- choices=[],
- label='Rear ports',
- help_text='Select one rear port assignment for each front port being created.',
- )
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'rear_port_set', 'mark_connected', 'description',
- 'tags',
- )
-
- def __init__(self, *args, **kwargs):
- super().__init__(*args, **kwargs)
-
- device = Device.objects.get(
- pk=self.initial.get('device') or self.data.get('device')
- )
-
- # Determine which rear port positions are occupied. These will be excluded from the list of available
- # mappings.
- occupied_port_positions = [
- (front_port.rear_port_id, front_port.rear_port_position)
- for front_port in device.frontports.all()
- ]
-
- # Populate rear port choices
- choices = []
- rear_ports = RearPort.objects.filter(device=device)
- for rear_port in rear_ports:
- for i in range(1, rear_port.positions + 1):
- if (rear_port.pk, i) not in occupied_port_positions:
- choices.append(
- ('{}:{}'.format(rear_port.pk, i), '{}:{}'.format(rear_port.name, i))
- )
- self.fields['rear_port_set'].choices = choices
-
- def clean(self):
- super().clean()
-
- # Validate that the number of ports being created equals the number of selected (rear port, position) tuples
- front_port_count = len(self.cleaned_data['name_pattern'])
- rear_port_count = len(self.cleaned_data['rear_port_set'])
- if front_port_count != rear_port_count:
- raise forms.ValidationError({
- 'rear_port_set': 'The provided name pattern will create {} ports, however {} rear port assignments '
- 'were selected. These counts must match.'.format(front_port_count, rear_port_count)
- })
-
- def get_iterative_data(self, iteration):
-
- # Assign rear port and position from selected set
- rear_port, position = self.cleaned_data['rear_port_set'][iteration].split(':')
-
- return {
- 'rear_port': int(rear_port),
- 'rear_port_position': int(position),
- }
-
-
-class RearPortCreateForm(ComponentCreateForm):
- model = RearPort
- type = forms.ChoiceField(
- choices=PortTypeChoices,
- widget=StaticSelect(),
- )
- color = ColorField(
- required=False
- )
- positions = forms.IntegerField(
- min_value=REARPORT_POSITIONS_MIN,
- max_value=REARPORT_POSITIONS_MAX,
- initial=1,
- help_text='The number of front ports which may be mapped to each rear port'
- )
- field_order = (
- 'device', 'name_pattern', 'label_pattern', 'type', 'color', 'positions', 'mark_connected', 'description',
- 'tags',
- )
-
-
-class ModuleBayCreateForm(ComponentCreateForm):
- model = ModuleBay
- field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class DeviceBayCreateForm(ComponentCreateForm):
- model = DeviceBay
- field_order = ('device', 'name_pattern', 'label_pattern', 'description', 'tags')
-
-
-class InventoryItemCreateForm(ComponentCreateForm):
- model = InventoryItem
- 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
- )
- part_id = forms.CharField(
- max_length=50,
- required=False,
- label='Part ID'
- )
- serial = forms.CharField(
- max_length=50,
- required=False,
- )
- asset_tag = forms.CharField(
- max_length=50,
- required=False,
- )
- field_order = (
- 'device', 'parent', 'name_pattern', 'label_pattern', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
- 'description', 'tags',
- )
diff --git a/netbox/dcim/migrations/0147_inventoryitem_component.py b/netbox/dcim/migrations/0147_inventoryitem_component.py
new file mode 100644
index 000000000..36085c35d
--- /dev/null
+++ b/netbox/dcim/migrations/0147_inventoryitem_component.py
@@ -0,0 +1,23 @@
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('contenttypes', '0002_remove_content_type_name'),
+ ('dcim', '0146_inventoryitemrole'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='inventoryitem',
+ name='component_id',
+ field=models.PositiveBigIntegerField(blank=True, null=True),
+ ),
+ migrations.AddField(
+ model_name='inventoryitem',
+ name='component_type',
+ field=models.ForeignKey(blank=True, limit_choices_to=models.Q(('app_label', 'dcim'), ('model__in', ('consoleport', 'consoleserverport', 'frontport', 'interface', 'poweroutlet', 'powerport', 'rearport'))), null=True, on_delete=django.db.models.deletion.PROTECT, related_name='+', to='contenttypes.contenttype'),
+ ),
+ ]
diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py
index cb38d8683..cdfaa7c89 100644
--- a/netbox/dcim/models/device_components.py
+++ b/netbox/dcim/models/device_components.py
@@ -97,6 +97,12 @@ class ModularComponentModel(ComponentModel):
blank=True,
null=True
)
+ inventory_items = GenericRelation(
+ to='dcim.InventoryItem',
+ content_type_field='component_type',
+ object_id_field='component_id',
+ related_name='%(class)ss',
+ )
class Meta:
abstract = True
@@ -994,6 +1000,22 @@ class InventoryItem(MPTTModel, ComponentModel):
null=True,
db_index=True
)
+ component_type = models.ForeignKey(
+ to=ContentType,
+ limit_choices_to=MODULAR_COMPONENT_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,
diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py
index 9472be541..4eda4a937 100644
--- a/netbox/dcim/tables/devices.py
+++ b/netbox/dcim/tables/devices.py
@@ -780,6 +780,9 @@ class InventoryItemTable(DeviceComponentTable):
manufacturer = tables.Column(
linkify=True
)
+ component = tables.Column(
+ linkify=True
+ )
discovered = BooleanColumn()
tags = TagColumn(
url_name='dcim:inventoryitem_list'
@@ -790,9 +793,11 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem
fields = (
'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
- 'description', 'discovered', 'tags',
+ 'component', 'description', 'discovered', 'tags',
+ )
+ default_columns = (
+ 'pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag',
)
- default_columns = ('pk', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag')
class DeviceInventoryItemTable(InventoryItemTable):
@@ -810,11 +815,11 @@ class DeviceInventoryItemTable(InventoryItemTable):
class Meta(BaseTable.Meta):
model = InventoryItem
fields = (
- 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description',
- 'discovered', 'tags', 'actions',
+ 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
+ 'description', 'discovered', 'tags', 'actions',
)
default_columns = (
- 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'actions',
+ 'pk', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component', 'actions',
)
diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py
index e6ea10499..b3c41e277 100644
--- a/netbox/dcim/tests/test_api.py
+++ b/netbox/dcim/tests/test_api.py
@@ -1632,9 +1632,16 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
)
InventoryItemRole.objects.bulk_create(roles)
- InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer)
- InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer)
- InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer)
+ interfaces = (
+ Interface(device=device, name='Interface 1'),
+ Interface(device=device, name='Interface 2'),
+ Interface(device=device, name='Interface 3'),
+ )
+ Interface.objects.bulk_create(interfaces)
+
+ InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer, component=interfaces[0])
+ InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer, component=interfaces[1])
+ InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer, component=interfaces[2])
cls.create_data = [
{
@@ -1642,18 +1649,24 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
'name': 'Inventory Item 4',
'role': roles[1].pk,
'manufacturer': manufacturer.pk,
+ 'component_type': 'dcim.interface',
+ 'component_id': interfaces[0].pk,
},
{
'device': device.pk,
'name': 'Inventory Item 5',
'role': roles[1].pk,
'manufacturer': manufacturer.pk,
+ 'component_type': 'dcim.interface',
+ 'component_id': interfaces[1].pk,
},
{
'device': device.pk,
'name': 'Inventory Item 6',
'role': roles[1].pk,
'manufacturer': manufacturer.pk,
+ 'component_type': 'dcim.interface',
+ 'component_id': interfaces[2].pk,
},
]
diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py
index a808aeda2..f53705336 100644
--- a/netbox/dcim/tests/test_filtersets.py
+++ b/netbox/dcim/tests/test_filtersets.py
@@ -3004,10 +3004,16 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
)
InventoryItemRole.objects.bulk_create(roles)
+ components = (
+ Interface.objects.create(device=devices[0], name='Interface 1'),
+ ConsolePort.objects.create(device=devices[1], name='Console Port 1'),
+ ConsoleServerPort.objects.create(device=devices[2], name='Console Server Port 1'),
+ )
+
inventory_items = (
- InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First'),
- InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second'),
- InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third'),
+ InventoryItem(device=devices[0], role=roles[0], manufacturer=manufacturers[0], name='Inventory Item 1', label='A', part_id='1001', serial='ABC', asset_tag='1001', discovered=True, description='First', component=components[0]),
+ InventoryItem(device=devices[1], role=roles[1], manufacturer=manufacturers[1], name='Inventory Item 2', label='B', part_id='1002', serial='DEF', asset_tag='1002', discovered=True, description='Second', component=components[1]),
+ InventoryItem(device=devices[2], role=roles[2], manufacturer=manufacturers[2], name='Inventory Item 3', label='C', part_id='1003', serial='GHI', asset_tag='1003', discovered=False, description='Third', component=components[2]),
)
for i in inventory_items:
i.save()
@@ -3103,6 +3109,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+ def test_component_type(self):
+ params = {'component_type': 'dcim.interface'}
+ self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1)
+
class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = InventoryItemRole.objects.all()
diff --git a/netbox/dcim/tests/test_forms.py b/netbox/dcim/tests/test_forms.py
index 3b2a9eff0..4c5de1284 100644
--- a/netbox/dcim/tests/test_forms.py
+++ b/netbox/dcim/tests/test_forms.py
@@ -118,41 +118,27 @@ class DeviceTestCase(TestCase):
class LabelTestCase(TestCase):
- @classmethod
- def setUpTestData(cls):
- site = Site.objects.create(name='Site 2', slug='site-2')
- manufacturer = Manufacturer.objects.create(name='Manufacturer 2', slug='manufacturer-2')
- cls.device_type = DeviceType.objects.create(
- manufacturer=manufacturer, model='Device Type 2', slug='device-type-2', u_height=1
- )
- device_role = DeviceRole.objects.create(
- name='Device Role 2', slug='device-role-2', color='ffff00'
- )
- cls.device = Device.objects.create(
- name='Device 2', device_type=cls.device_type, device_role=device_role, site=site
- )
-
def test_interface_label_count_valid(self):
- """Test that a `label` can be generated for each generated `name` from `name_pattern` on InterfaceCreateForm"""
+ """
+ Test that generating an equal number of names and labels passes form validation.
+ """
interface_data = {
- 'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-9]',
- 'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
}
- form = InterfaceCreateForm(interface_data)
+ form = ComponentCreateForm(interface_data)
self.assertTrue(form.is_valid())
def test_interface_label_count_mismatch(self):
- """Test that a `label` cannot be generated for each generated `name` from `name_pattern` due to invalid `label_pattern` on InterfaceCreateForm"""
+ """
+ Check that attempting to generate a differing number of names and labels results in a validation error.
+ """
bad_interface_data = {
- 'device': self.device.pk,
'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-1]',
- 'type': InterfaceTypeChoices.TYPE_100ME_FIXED,
}
- form = InterfaceCreateForm(bad_interface_data)
+ form = ComponentCreateForm(bad_interface_data)
self.assertFalse(form.is_valid())
self.assertIn('label_pattern', form.errors)
diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py
index bfa2fecae..bee7f9ef0 100644
--- a/netbox/dcim/views.py
+++ b/netbox/dcim/views.py
@@ -1054,7 +1054,6 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
class ConsolePortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsolePortTemplate.objects.all()
- form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm
@@ -1088,7 +1087,6 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPortTemplate.objects.all()
- form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm
@@ -1122,7 +1120,6 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerPortTemplateCreateView(generic.ComponentCreateView):
queryset = PowerPortTemplate.objects.all()
- form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm
@@ -1156,7 +1153,6 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerOutletTemplateCreateView(generic.ComponentCreateView):
queryset = PowerOutletTemplate.objects.all()
- form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm
@@ -1190,7 +1186,6 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
class InterfaceTemplateCreateView(generic.ComponentCreateView):
queryset = InterfaceTemplate.objects.all()
- form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm
@@ -1227,6 +1222,14 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm
+ def initialize_forms(self, request):
+ form, model_form = super().initialize_forms(request)
+
+ model_form.fields.pop('rear_port')
+ model_form.fields.pop('rear_port_position')
+
+ return form, model_form
+
class FrontPortTemplateEditView(generic.ObjectEditView):
queryset = FrontPortTemplate.objects.all()
@@ -1258,7 +1261,6 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
class RearPortTemplateCreateView(generic.ComponentCreateView):
queryset = RearPortTemplate.objects.all()
- form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm
@@ -1292,7 +1294,6 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
class ModuleBayTemplateCreateView(generic.ComponentCreateView):
queryset = ModuleBayTemplate.objects.all()
- form = forms.ModuleBayTemplateCreateForm
model_form = forms.ModuleBayTemplateForm
@@ -1326,7 +1327,6 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceBayTemplateCreateView(generic.ComponentCreateView):
queryset = DeviceBayTemplate.objects.all()
- form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm
@@ -1741,7 +1741,6 @@ class ConsolePortView(generic.ObjectView):
class ConsolePortCreateView(generic.ComponentCreateView):
queryset = ConsolePort.objects.all()
- form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm
@@ -1800,7 +1799,6 @@ class ConsoleServerPortView(generic.ObjectView):
class ConsoleServerPortCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPort.objects.all()
- form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm
@@ -1859,7 +1857,6 @@ class PowerPortView(generic.ObjectView):
class PowerPortCreateView(generic.ComponentCreateView):
queryset = PowerPort.objects.all()
- form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm
@@ -1918,7 +1915,6 @@ class PowerOutletView(generic.ObjectView):
class PowerOutletCreateView(generic.ComponentCreateView):
queryset = PowerOutlet.objects.all()
- form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm
@@ -2012,35 +2008,35 @@ class InterfaceView(generic.ObjectView):
class InterfaceCreateView(generic.ComponentCreateView):
queryset = Interface.objects.all()
- form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm
- template_name = 'dcim/interface_create.html'
+ # template_name = 'dcim/interface_create.html'
- def post(self, request):
- """
- Override inherited post() method to handle request to assign newly created
- interface objects (first object) to an IP Address object.
- """
- form = self.form(request.POST, initial=request.GET)
- new_objs = self.validate_form(request, form)
-
- if form.is_valid() and not form.errors:
- if '_addanother' in request.POST:
- return redirect(request.get_full_path())
- elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
- request.user.has_perm('ipam.add_ipaddress'):
- first_obj = new_objs[0].pk
- return redirect(
- f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
- )
- else:
- return redirect(self.get_return_url(request))
-
- return render(request, self.template_name, {
- 'obj_type': self.queryset.model._meta.verbose_name,
- 'form': form,
- 'return_url': self.get_return_url(request),
- })
+ # TODO: Figure out what to do with this
+ # def post(self, request):
+ # """
+ # Override inherited post() method to handle request to assign newly created
+ # interface objects (first object) to an IP Address object.
+ # """
+ # form = self.form(request.POST, initial=request.GET)
+ # new_objs = self.validate_form(request, form)
+ #
+ # if form.is_valid() and not form.errors:
+ # if '_addanother' in request.POST:
+ # return redirect(request.get_full_path())
+ # elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
+ # request.user.has_perm('ipam.add_ipaddress'):
+ # first_obj = new_objs[0].pk
+ # return redirect(
+ # f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}'
+ # )
+ # else:
+ # return redirect(self.get_return_url(request))
+ #
+ # return render(request, self.template_name, {
+ # 'obj_type': self.queryset.model._meta.verbose_name,
+ # 'form': form,
+ # 'return_url': self.get_return_url(request),
+ # })
class InterfaceEditView(generic.ObjectEditView):
@@ -2101,6 +2097,14 @@ class FrontPortCreateView(generic.ComponentCreateView):
form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm
+ def initialize_forms(self, request):
+ form, model_form = super().initialize_forms(request)
+
+ model_form.fields.pop('rear_port')
+ model_form.fields.pop('rear_port_position')
+
+ return form, model_form
+
class FrontPortEditView(generic.ObjectEditView):
queryset = FrontPort.objects.all()
@@ -2157,7 +2161,6 @@ class RearPortView(generic.ObjectView):
class RearPortCreateView(generic.ComponentCreateView):
queryset = RearPort.objects.all()
- form = forms.RearPortCreateForm
model_form = forms.RearPortForm
@@ -2216,7 +2219,6 @@ class ModuleBayView(generic.ObjectView):
class ModuleBayCreateView(generic.ComponentCreateView):
queryset = ModuleBay.objects.all()
- form = forms.ModuleBayCreateForm
model_form = forms.ModuleBayForm
@@ -2271,7 +2273,6 @@ class DeviceBayView(generic.ObjectView):
class DeviceBayCreateView(generic.ComponentCreateView):
queryset = DeviceBay.objects.all()
- form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm
@@ -2397,7 +2398,6 @@ class InventoryItemEditView(generic.ObjectEditView):
class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all()
- form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm
diff --git a/netbox/netbox/views/generic/object_views.py b/netbox/netbox/views/generic/object_views.py
index fed4b2f60..577dfa4bf 100644
--- a/netbox/netbox/views/generic/object_views.py
+++ b/netbox/netbox/views/generic/object_views.py
@@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction
from django.db.models import ProtectedError
+from django.forms.widgets import HiddenInput
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.html import escape
@@ -14,6 +15,7 @@ from django.utils.safestring import mark_safe
from django.views.generic import View
from django_tables2.export import TableExport
+from dcim.forms.object_create import ComponentCreateForm
from extras.models import ExportTemplate
from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror
@@ -674,33 +676,46 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Device/VirtualMachine components
#
-# TODO: Replace with BulkCreateView
class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
"""
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
"""
queryset = None
- form = None
+ form = ComponentCreateForm
model_form = None
- template_name = 'generic/object_edit.html'
+ template_name = 'dcim/component_create.html'
+ patterned_fields = ('name', 'label')
def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add')
- def get(self, request):
+ def initialize_forms(self, request):
+ data = request.POST if request.method == 'POST' else None
+ initial_data = normalize_querydict(request.GET)
- form = self.form(initial=request.GET)
+ form = self.form(data=data, initial=request.GET)
+ model_form = self.model_form(data=data, initial=initial_data)
+
+ # These fields will be set from the pattern values
+ for field_name in self.patterned_fields:
+ model_form.fields[field_name].widget = HiddenInput()
+
+ return form, model_form
+
+ def get(self, request):
+ form, model_form = self.initialize_forms(request)
return render(request, self.template_name, {
- 'obj': self.queryset.model(),
+ 'obj': self.queryset.model,
'obj_type': self.queryset.model._meta.verbose_name,
- 'form': form,
+ 'replication_form': form,
+ 'form': model_form,
'return_url': self.get_return_url(request),
})
def post(self, request):
- logger = logging.getLogger('netbox.views.ComponentCreateView')
- form = self.form(request.POST, initial=request.GET)
+ form, model_form = self.initialize_forms(request)
+
self.validate_form(request, form)
if form.is_valid() and not form.errors:
@@ -710,8 +725,10 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
return redirect(self.get_return_url(request))
return render(request, self.template_name, {
+ 'obj': self.queryset.model,
'obj_type': self.queryset.model._meta.verbose_name,
- 'form': form,
+ 'replication_form': form,
+ 'form': model_form,
'return_url': self.get_return_url(request),
})
@@ -720,7 +737,6 @@ class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View
Validate form values and set errors on the form object as they are detected. If
no errors are found, signal success messages.
"""
-
logger = logging.getLogger('netbox.views.ComponentCreateView')
if form.is_valid():
new_components = []
diff --git a/netbox/templates/dcim/component_create.html b/netbox/templates/dcim/component_create.html
new file mode 100644
index 000000000..a8750e20e
--- /dev/null
+++ b/netbox/templates/dcim/component_create.html
@@ -0,0 +1,7 @@
+{% extends 'generic/object_edit.html' %}
+{% load form_helpers %}
+
+{% block form %}
+ {% render_form replication_form %}
+ {{ block.super }}
+{% endblock form %}
diff --git a/netbox/templates/dcim/consoleport.html b/netbox/templates/dcim/consoleport.html
index 38cfb90ae..ed3c649dd 100644
--- a/netbox/templates/dcim/consoleport.html
+++ b/netbox/templates/dcim/consoleport.html
@@ -58,91 +58,92 @@
{% if object.mark_connected %}
-
Marked as connected
+
Marked as connected
{% elif object.cable %}
-
-
- Cable |
-
- {{ object.cable }}
-
-
-
- |
-
- {% if object.connected_endpoint %}
+
- {% else %}
-
- Not Connected
- {% if perms.dcim.add_cable %}
-
-
-
-
- {% endif %}
-
- {% endif %}
-
+ {% if object.connected_endpoint %}
+
+ Device |
+
+ {{ object.connected_endpoint.device }}
+ |
+
+
+ Name |
+
+ {{ object.connected_endpoint.name }}
+ |
+
+
+ Type |
+ {{ object.connected_endpoint.get_type_display|placeholder }} |
+
+
+ Description |
+ {{ object.connected_endpoint.description|placeholder }} |
+
+
+ Path Status |
+
+ {% if object.path.is_active %}
+ Reachable
+ {% else %}
+ Not Reachable
+ {% endif %}
+ |
+
+ {% endif %}
+
+ {% else %}
+
+ Not Connected
+ {% if perms.dcim.add_cable %}
+
+
+
+
+ {% endif %}
+
+ {% endif %}
+
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/consoleserverport.html b/netbox/templates/dcim/consoleserverport.html
index b44c4a9b8..b64e352e7 100644
--- a/netbox/templates/dcim/consoleserverport.html
+++ b/netbox/templates/dcim/consoleserverport.html
@@ -143,6 +143,7 @@
{% endif %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/frontport.html b/netbox/templates/dcim/frontport.html
index 05be82fc9..e11036f8a 100644
--- a/netbox/templates/dcim/frontport.html
+++ b/netbox/templates/dcim/frontport.html
@@ -129,6 +129,7 @@
{% endif %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/inc/panels/inventory_items.html b/netbox/templates/dcim/inc/panels/inventory_items.html
new file mode 100644
index 000000000..c65b342b2
--- /dev/null
+++ b/netbox/templates/dcim/inc/panels/inventory_items.html
@@ -0,0 +1,59 @@
+{% load helpers %}
+
+
+
+
+
+
+
+ Name |
+ Label |
+ Role |
+ |
+
+
+
+ {% for item in object.inventory_items.all %}
+
+
+ {{ item.name }}
+ |
+
+ {{ item.label|placeholder }}
+ |
+
+ {% if item.role %}
+ {{ item.role }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+ {% if perms.dcim.change_inventoryitem %}
+
+
+
+ {% endif %}
+ {% if perms.ipam.delete_inventoryitem %}
+
+
+
+ {% endif %}
+ |
+
+ {% empty %}
+
+ None |
+
+ {% endfor %}
+
+
+
+
+
diff --git a/netbox/templates/dcim/interface.html b/netbox/templates/dcim/interface.html
index bd0569c39..a8b8da5cb 100644
--- a/netbox/templates/dcim/interface.html
+++ b/netbox/templates/dcim/interface.html
@@ -448,6 +448,7 @@
{% endif %}
{% include 'ipam/inc/panels/fhrp_groups.html' %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/interface_create.html b/netbox/templates/dcim/interface_create.html
deleted file mode 100644
index 6b5486eff..000000000
--- a/netbox/templates/dcim/interface_create.html
+++ /dev/null
@@ -1,16 +0,0 @@
-{% extends 'generic/object_edit.html' %}
-
-{% block buttons %}
- Cancel
- {% if component_type == 'interface' and perms.ipam.add_ipaddress %}
-
- {% endif %}
-
-
-{% endblock %}
\ No newline at end of file
diff --git a/netbox/templates/dcim/inventoryitem.html b/netbox/templates/dcim/inventoryitem.html
index 7de303656..0e30c5c8c 100644
--- a/netbox/templates/dcim/inventoryitem.html
+++ b/netbox/templates/dcim/inventoryitem.html
@@ -50,6 +50,16 @@
{% endif %}
+
+ Component |
+
+ {% if object.component %}
+ {{ object.component }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
Manufacturer |
diff --git a/netbox/templates/dcim/poweroutlet.html b/netbox/templates/dcim/poweroutlet.html
index 3f2c469af..90858c8d9 100644
--- a/netbox/templates/dcim/poweroutlet.html
+++ b/netbox/templates/dcim/poweroutlet.html
@@ -121,6 +121,7 @@
{% endif %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/powerport.html b/netbox/templates/dcim/powerport.html
index f38edec8e..1ee85b6ba 100644
--- a/netbox/templates/dcim/powerport.html
+++ b/netbox/templates/dcim/powerport.html
@@ -131,6 +131,7 @@
{% endif %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/dcim/rearport.html b/netbox/templates/dcim/rearport.html
index 311ccd7ff..c56bf0c4f 100644
--- a/netbox/templates/dcim/rearport.html
+++ b/netbox/templates/dcim/rearport.html
@@ -117,6 +117,7 @@
{% endif %}
+ {% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %}
diff --git a/netbox/templates/generic/object_edit.html b/netbox/templates/generic/object_edit.html
index c7b4c8a8b..5dc8f995d 100644
--- a/netbox/templates/generic/object_edit.html
+++ b/netbox/templates/generic/object_edit.html
@@ -29,29 +29,35 @@
{% endif %}
- |