Merge pull request #8176 from netbox-community/7846-inventoryitem-component

Closes #7846: Associate inventory items with device components
This commit is contained in:
Jeremy Stretch 2021-12-28 11:48:36 -05:00 committed by GitHub
commit e9910d1fe2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
31 changed files with 572 additions and 929 deletions

View File

@ -48,6 +48,7 @@ FIELD_CHOICES = {
* [#7681](https://github.com/netbox-community/netbox/issues/7681) - Add `service_id` field for provider networks * [#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 * [#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 * [#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 * [#8168](https://github.com/netbox-community/netbox/issues/8168) - Add `min_vid` and `max_vid` fields to VLAN group
### Other Changes ### Other Changes
@ -76,7 +77,8 @@ FIELD_CHOICES = {
* dcim.Interface * dcim.Interface
* Added `module` field * Added `module` field
* dcim.InventoryItem * dcim.InventoryItem
* Added `role` field * Added `component_type`, `component_id`, and `role` fields
* Added read-only `component` field
* dcim.PowerPort * dcim.PowerPort
* Added `module` field * Added `module` field
* dcim.PowerOutlet * dcim.PowerOutlet

View File

@ -810,17 +810,32 @@ class InventoryItemSerializer(PrimaryModelSerializer):
url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail') url = serializers.HyperlinkedIdentityField(view_name='dcim-api:inventoryitem-detail')
device = NestedDeviceSerializer() device = NestedDeviceSerializer()
parent = serializers.PrimaryKeyRelatedField(queryset=InventoryItem.objects.all(), allow_null=True, default=None) 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) 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) _depth = serializers.IntegerField(source='level', read_only=True)
class Meta: class Meta:
model = InventoryItem model = InventoryItem
fields = [ fields = [
'id', 'url', 'display', 'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', '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 # Device component roles

View File

@ -50,16 +50,31 @@ NONCONNECTABLE_IFACE_TYPES = VIRTUAL_IFACE_TYPES + WIRELESS_IFACE_TYPES
# #
# PowerFeeds # Power feeds
# #
POWERFEED_VOLTAGE_DEFAULT = 120 POWERFEED_VOLTAGE_DEFAULT = 120
POWERFEED_AMPERAGE_DEFAULT = 20 POWERFEED_AMPERAGE_DEFAULT = 20
POWERFEED_MAX_UTILIZATION_DEFAULT = 80 # Percentage 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 # Cabling and connections
# #

View File

@ -1294,6 +1294,8 @@ class InventoryItemFilterSet(PrimaryModelFilterSet, DeviceComponentFilterSet):
to_field_name='slug', to_field_name='slug',
label='Role (slug)', label='Role (slug)',
) )
component_type = ContentTypeFilter()
component_id = MultiValueNumberFilter()
serial = django_filters.CharFilter( serial = django_filters.CharFilter(
lookup_expr='iexact' lookup_expr='iexact'
) )

View File

@ -4,7 +4,7 @@ from dcim.models import *
from extras.forms import CustomFieldsMixin from extras.forms import CustomFieldsMixin
from extras.models import Tag from extras.models import Tag
from utilities.forms import DynamicModelMultipleChoiceField, form_from_model from utilities.forms import DynamicModelMultipleChoiceField, form_from_model
from .object_create import ComponentForm from .object_create import ComponentCreateForm
__all__ = ( __all__ = (
'ConsolePortBulkCreateForm', 'ConsolePortBulkCreateForm',
@ -24,7 +24,7 @@ __all__ = (
# Device components # Device components
# #
class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentForm): class DeviceBulkAddComponentForm(CustomFieldsMixin, ComponentCreateForm):
pk = forms.ModelMultipleChoiceField( pk = forms.ModelMultipleChoiceField(
queryset=Device.objects.all(), queryset=Device.objects.all(),
widget=forms.MultipleHiddenInput() widget=forms.MultipleHiddenInput()

View File

@ -12,8 +12,8 @@ from extras.models import Tag
from ipam.models import IPAddress, VLAN, VLANGroup, ASN from ipam.models import IPAddress, VLAN, VLANGroup, ASN
from tenancy.forms import TenancyForm from tenancy.forms import TenancyForm
from utilities.forms import ( from utilities.forms import (
APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, DynamicModelChoiceField, APISelect, add_blank_choice, BootstrapMixin, ClearableFileInput, CommentField, ContentTypeChoiceField,
DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea, DynamicModelChoiceField, DynamicModelMultipleChoiceField, JSONField, NumericArrayField, SelectWithPK, SmallTextarea,
SlugField, StaticSelect, SlugField, StaticSelect,
) )
from virtualization.models import Cluster, ClusterGroup from virtualization.models import Cluster, ClusterGroup
@ -957,6 +957,7 @@ class ConsolePortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(),
'type': StaticSelect,
} }
@ -969,6 +970,7 @@ class ConsoleServerPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(),
'type': StaticSelect,
} }
@ -981,10 +983,19 @@ class PowerPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
'module_type': forms.HiddenInput(), 'module_type': forms.HiddenInput(),
'type': StaticSelect(),
} }
class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm): class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
power_port = DynamicModelChoiceField(
queryset=PowerPortTemplate.objects.all(),
required=False,
query_params={
'devicetype_id': '$device_type',
}
)
class Meta: class Meta:
model = PowerOutletTemplate model = PowerOutletTemplate
fields = [ fields = [
@ -993,18 +1004,10 @@ class PowerOutletTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
'module_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 InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
@ -1020,6 +1023,14 @@ class InterfaceTemplateForm(BootstrapMixin, forms.ModelForm):
class FrontPortTemplateForm(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: class Meta:
model = FrontPortTemplate model = FrontPortTemplate
fields = [ fields = [
@ -1029,19 +1040,9 @@ class FrontPortTemplateForm(BootstrapMixin, forms.ModelForm):
widgets = { widgets = {
'device_type': forms.HiddenInput(), 'device_type': forms.HiddenInput(),
'module_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 RearPortTemplateForm(BootstrapMixin, forms.ModelForm):
class Meta: class Meta:
@ -1095,6 +1096,8 @@ class ConsolePortForm(CustomFieldModelForm):
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
} }
@ -1111,6 +1114,8 @@ class ConsoleServerPortForm(CustomFieldModelForm):
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(),
'speed': StaticSelect(),
} }
@ -1128,13 +1133,17 @@ class PowerPortForm(CustomFieldModelForm):
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(),
} }
class PowerOutletForm(CustomFieldModelForm): class PowerOutletForm(CustomFieldModelForm):
power_port = forms.ModelChoiceField( power_port = DynamicModelChoiceField(
queryset=PowerPort.objects.all(), queryset=PowerPort.objects.all(),
required=False required=False,
query_params={
'device_id': '$device',
}
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
@ -1148,34 +1157,34 @@ class PowerOutletForm(CustomFieldModelForm):
] ]
widgets = { widgets = {
'device': forms.HiddenInput(), '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): class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
label='Parent interface' label='Parent interface',
query_params={
'device_id': '$device',
}
) )
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
label='Bridged interface' label='Bridged interface',
query_params={
'device_id': '$device',
}
) )
lag = DynamicModelChoiceField( lag = DynamicModelChoiceField(
queryset=Interface.objects.all(), queryset=Interface.objects.all(),
required=False, required=False,
label='LAG interface', label='LAG interface',
query_params={ query_params={
'device_id': '$device',
'type': 'lag', 'type': 'lag',
} }
) )
@ -1203,6 +1212,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Untagged VLAN', label='Untagged VLAN',
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
} }
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
@ -1211,6 +1221,7 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Tagged VLANs', label='Tagged VLANs',
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_device': '$device',
} }
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
@ -1225,6 +1236,17 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'mgmt_only', 'mark_connected', 'description', 'mode', 'rf_role', 'rf_channel', 'rf_channel_frequency', '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', '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 = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(), 'type': StaticSelect(),
@ -1241,26 +1263,14 @@ class InterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
'rf_channel_width': "Populated by selected channel (if set)", '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): class FrontPortForm(CustomFieldModelForm):
rear_port = DynamicModelChoiceField(
queryset=RearPort.objects.all(),
query_params={
'device_id': '$device',
}
)
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -1275,18 +1285,8 @@ class FrontPortForm(CustomFieldModelForm):
widgets = { widgets = {
'device': forms.HiddenInput(), 'device': forms.HiddenInput(),
'type': StaticSelect(), '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): class RearPortForm(CustomFieldModelForm):
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
@ -1358,9 +1358,6 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form):
class InventoryItemForm(CustomFieldModelForm): class InventoryItemForm(CustomFieldModelForm):
device = DynamicModelChoiceField(
queryset=Device.objects.all()
)
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=InventoryItem.objects.all(), queryset=InventoryItem.objects.all(),
required=False, required=False,
@ -1376,6 +1373,15 @@ class InventoryItemForm(CustomFieldModelForm):
queryset=Manufacturer.objects.all(), queryset=Manufacturer.objects.all(),
required=False 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( tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(), queryset=Tag.objects.all(),
required=False required=False
@ -1385,8 +1391,16 @@ class InventoryItemForm(CustomFieldModelForm):
model = InventoryItem model = InventoryItem
fields = [ fields = [
'device', 'parent', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', '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(),
}
# #

View File

@ -1,43 +1,21 @@
from django import forms from django import forms
from dcim.choices import *
from dcim.constants import *
from dcim.models import * from dcim.models import *
from extras.forms import CustomFieldModelForm, CustomFieldsMixin from extras.forms import CustomFieldModelForm
from extras.models import Tag from extras.models import Tag
from ipam.models import VLAN
from utilities.forms import ( from utilities.forms import (
add_blank_choice, BootstrapMixin, ColorField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
ExpandableNameField, StaticSelect,
) )
from wireless.choices import *
from .common import InterfaceCommonForm
__all__ = ( __all__ = (
'ConsolePortCreateForm', 'ComponentCreateForm',
'ConsolePortTemplateCreateForm',
'ConsoleServerPortCreateForm',
'ConsoleServerPortTemplateCreateForm',
'DeviceBayCreateForm',
'DeviceBayTemplateCreateForm',
'FrontPortCreateForm', 'FrontPortCreateForm',
'FrontPortTemplateCreateForm', 'FrontPortTemplateCreateForm',
'InterfaceCreateForm',
'InterfaceTemplateCreateForm',
'InventoryItemCreateForm',
'ModuleBayCreateForm',
'ModuleBayTemplateCreateForm',
'PowerOutletCreateForm',
'PowerOutletTemplateCreateForm',
'PowerPortCreateForm',
'PowerPortTemplateCreateForm',
'RearPortCreateForm',
'RearPortTemplateCreateForm',
'VirtualChassisCreateForm', '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 Subclass this form when facilitating the creation of one or more device component or component templates based on
a name pattern. a name pattern.
@ -65,6 +43,97 @@ class ComponentForm(BootstrapMixin, forms.Form):
}, code='label_pattern_mismatch') }, 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): class VirtualChassisCreateForm(CustomFieldModelForm):
region = DynamicModelChoiceField( region = DynamicModelChoiceField(
queryset=Region.objects.all(), queryset=Region.objects.all(),
@ -138,549 +207,3 @@ class VirtualChassisCreateForm(CustomFieldModelForm):
member.save() member.save()
return instance 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',
)

View File

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

View File

@ -97,6 +97,12 @@ class ModularComponentModel(ComponentModel):
blank=True, blank=True,
null=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: class Meta:
abstract = True abstract = True
@ -994,6 +1000,22 @@ class InventoryItem(MPTTModel, ComponentModel):
null=True, null=True,
db_index=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( role = models.ForeignKey(
to='dcim.InventoryItemRole', to='dcim.InventoryItemRole',
on_delete=models.PROTECT, on_delete=models.PROTECT,

View File

@ -780,6 +780,9 @@ class InventoryItemTable(DeviceComponentTable):
manufacturer = tables.Column( manufacturer = tables.Column(
linkify=True linkify=True
) )
component = tables.Column(
linkify=True
)
discovered = BooleanColumn() discovered = BooleanColumn()
tags = TagColumn( tags = TagColumn(
url_name='dcim:inventoryitem_list' url_name='dcim:inventoryitem_list'
@ -790,9 +793,11 @@ class InventoryItemTable(DeviceComponentTable):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'device', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', '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): class DeviceInventoryItemTable(InventoryItemTable):
@ -810,11 +815,11 @@ class DeviceInventoryItemTable(InventoryItemTable):
class Meta(BaseTable.Meta): class Meta(BaseTable.Meta):
model = InventoryItem model = InventoryItem
fields = ( fields = (
'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'description', 'pk', 'id', 'name', 'label', 'role', 'manufacturer', 'part_id', 'serial', 'asset_tag', 'component',
'discovered', 'tags', 'actions', 'description', 'discovered', 'tags', 'actions',
) )
default_columns = ( 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',
) )

View File

@ -1632,9 +1632,16 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
) )
InventoryItemRole.objects.bulk_create(roles) InventoryItemRole.objects.bulk_create(roles)
InventoryItem.objects.create(device=device, name='Inventory Item 1', role=roles[0], manufacturer=manufacturer) interfaces = (
InventoryItem.objects.create(device=device, name='Inventory Item 2', role=roles[0], manufacturer=manufacturer) Interface(device=device, name='Interface 1'),
InventoryItem.objects.create(device=device, name='Inventory Item 3', role=roles[0], manufacturer=manufacturer) 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 = [ cls.create_data = [
{ {
@ -1642,18 +1649,24 @@ class InventoryItemTest(APIViewTestCases.APIViewTestCase):
'name': 'Inventory Item 4', 'name': 'Inventory Item 4',
'role': roles[1].pk, 'role': roles[1].pk,
'manufacturer': manufacturer.pk, 'manufacturer': manufacturer.pk,
'component_type': 'dcim.interface',
'component_id': interfaces[0].pk,
}, },
{ {
'device': device.pk, 'device': device.pk,
'name': 'Inventory Item 5', 'name': 'Inventory Item 5',
'role': roles[1].pk, 'role': roles[1].pk,
'manufacturer': manufacturer.pk, 'manufacturer': manufacturer.pk,
'component_type': 'dcim.interface',
'component_id': interfaces[1].pk,
}, },
{ {
'device': device.pk, 'device': device.pk,
'name': 'Inventory Item 6', 'name': 'Inventory Item 6',
'role': roles[1].pk, 'role': roles[1].pk,
'manufacturer': manufacturer.pk, 'manufacturer': manufacturer.pk,
'component_type': 'dcim.interface',
'component_id': interfaces[2].pk,
}, },
] ]

View File

@ -3004,10 +3004,16 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
) )
InventoryItemRole.objects.bulk_create(roles) 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 = ( 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[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'), 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'), 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: for i in inventory_items:
i.save() i.save()
@ -3103,6 +3109,10 @@ class InventoryItemTestCase(TestCase, ChangeLoggedFilterSetTests):
params = {'serial': 'abc'} params = {'serial': 'abc'}
self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) 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): class InventoryItemRoleTestCase(TestCase, ChangeLoggedFilterSetTests):
queryset = InventoryItemRole.objects.all() queryset = InventoryItemRole.objects.all()

View File

@ -118,41 +118,27 @@ class DeviceTestCase(TestCase):
class LabelTestCase(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): 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 = { interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]', 'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[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()) self.assertTrue(form.is_valid())
def test_interface_label_count_mismatch(self): 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 = { bad_interface_data = {
'device': self.device.pk,
'name_pattern': 'eth[0-9]', 'name_pattern': 'eth[0-9]',
'label_pattern': 'Interface[0-1]', '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.assertFalse(form.is_valid())
self.assertIn('label_pattern', form.errors) self.assertIn('label_pattern', form.errors)

View File

@ -1054,7 +1054,6 @@ class ModuleTypeBulkDeleteView(generic.BulkDeleteView):
class ConsolePortTemplateCreateView(generic.ComponentCreateView): class ConsolePortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsolePortTemplate.objects.all() queryset = ConsolePortTemplate.objects.all()
form = forms.ConsolePortTemplateCreateForm
model_form = forms.ConsolePortTemplateForm model_form = forms.ConsolePortTemplateForm
@ -1088,7 +1087,6 @@ class ConsolePortTemplateBulkDeleteView(generic.BulkDeleteView):
class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView): class ConsoleServerPortTemplateCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPortTemplate.objects.all() queryset = ConsoleServerPortTemplate.objects.all()
form = forms.ConsoleServerPortTemplateCreateForm
model_form = forms.ConsoleServerPortTemplateForm model_form = forms.ConsoleServerPortTemplateForm
@ -1122,7 +1120,6 @@ class ConsoleServerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerPortTemplateCreateView(generic.ComponentCreateView): class PowerPortTemplateCreateView(generic.ComponentCreateView):
queryset = PowerPortTemplate.objects.all() queryset = PowerPortTemplate.objects.all()
form = forms.PowerPortTemplateCreateForm
model_form = forms.PowerPortTemplateForm model_form = forms.PowerPortTemplateForm
@ -1156,7 +1153,6 @@ class PowerPortTemplateBulkDeleteView(generic.BulkDeleteView):
class PowerOutletTemplateCreateView(generic.ComponentCreateView): class PowerOutletTemplateCreateView(generic.ComponentCreateView):
queryset = PowerOutletTemplate.objects.all() queryset = PowerOutletTemplate.objects.all()
form = forms.PowerOutletTemplateCreateForm
model_form = forms.PowerOutletTemplateForm model_form = forms.PowerOutletTemplateForm
@ -1190,7 +1186,6 @@ class PowerOutletTemplateBulkDeleteView(generic.BulkDeleteView):
class InterfaceTemplateCreateView(generic.ComponentCreateView): class InterfaceTemplateCreateView(generic.ComponentCreateView):
queryset = InterfaceTemplate.objects.all() queryset = InterfaceTemplate.objects.all()
form = forms.InterfaceTemplateCreateForm
model_form = forms.InterfaceTemplateForm model_form = forms.InterfaceTemplateForm
@ -1227,6 +1222,14 @@ class FrontPortTemplateCreateView(generic.ComponentCreateView):
form = forms.FrontPortTemplateCreateForm form = forms.FrontPortTemplateCreateForm
model_form = forms.FrontPortTemplateForm 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): class FrontPortTemplateEditView(generic.ObjectEditView):
queryset = FrontPortTemplate.objects.all() queryset = FrontPortTemplate.objects.all()
@ -1258,7 +1261,6 @@ class FrontPortTemplateBulkDeleteView(generic.BulkDeleteView):
class RearPortTemplateCreateView(generic.ComponentCreateView): class RearPortTemplateCreateView(generic.ComponentCreateView):
queryset = RearPortTemplate.objects.all() queryset = RearPortTemplate.objects.all()
form = forms.RearPortTemplateCreateForm
model_form = forms.RearPortTemplateForm model_form = forms.RearPortTemplateForm
@ -1292,7 +1294,6 @@ class RearPortTemplateBulkDeleteView(generic.BulkDeleteView):
class ModuleBayTemplateCreateView(generic.ComponentCreateView): class ModuleBayTemplateCreateView(generic.ComponentCreateView):
queryset = ModuleBayTemplate.objects.all() queryset = ModuleBayTemplate.objects.all()
form = forms.ModuleBayTemplateCreateForm
model_form = forms.ModuleBayTemplateForm model_form = forms.ModuleBayTemplateForm
@ -1326,7 +1327,6 @@ class ModuleBayTemplateBulkDeleteView(generic.BulkDeleteView):
class DeviceBayTemplateCreateView(generic.ComponentCreateView): class DeviceBayTemplateCreateView(generic.ComponentCreateView):
queryset = DeviceBayTemplate.objects.all() queryset = DeviceBayTemplate.objects.all()
form = forms.DeviceBayTemplateCreateForm
model_form = forms.DeviceBayTemplateForm model_form = forms.DeviceBayTemplateForm
@ -1741,7 +1741,6 @@ class ConsolePortView(generic.ObjectView):
class ConsolePortCreateView(generic.ComponentCreateView): class ConsolePortCreateView(generic.ComponentCreateView):
queryset = ConsolePort.objects.all() queryset = ConsolePort.objects.all()
form = forms.ConsolePortCreateForm
model_form = forms.ConsolePortForm model_form = forms.ConsolePortForm
@ -1800,7 +1799,6 @@ class ConsoleServerPortView(generic.ObjectView):
class ConsoleServerPortCreateView(generic.ComponentCreateView): class ConsoleServerPortCreateView(generic.ComponentCreateView):
queryset = ConsoleServerPort.objects.all() queryset = ConsoleServerPort.objects.all()
form = forms.ConsoleServerPortCreateForm
model_form = forms.ConsoleServerPortForm model_form = forms.ConsoleServerPortForm
@ -1859,7 +1857,6 @@ class PowerPortView(generic.ObjectView):
class PowerPortCreateView(generic.ComponentCreateView): class PowerPortCreateView(generic.ComponentCreateView):
queryset = PowerPort.objects.all() queryset = PowerPort.objects.all()
form = forms.PowerPortCreateForm
model_form = forms.PowerPortForm model_form = forms.PowerPortForm
@ -1918,7 +1915,6 @@ class PowerOutletView(generic.ObjectView):
class PowerOutletCreateView(generic.ComponentCreateView): class PowerOutletCreateView(generic.ComponentCreateView):
queryset = PowerOutlet.objects.all() queryset = PowerOutlet.objects.all()
form = forms.PowerOutletCreateForm
model_form = forms.PowerOutletForm model_form = forms.PowerOutletForm
@ -2012,35 +2008,35 @@ class InterfaceView(generic.ObjectView):
class InterfaceCreateView(generic.ComponentCreateView): class InterfaceCreateView(generic.ComponentCreateView):
queryset = Interface.objects.all() queryset = Interface.objects.all()
form = forms.InterfaceCreateForm
model_form = forms.InterfaceForm model_form = forms.InterfaceForm
template_name = 'dcim/interface_create.html' # template_name = 'dcim/interface_create.html'
def post(self, 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. # 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) # 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: # if form.is_valid() and not form.errors:
return redirect(request.get_full_path()) # if '_addanother' in request.POST:
elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \ # return redirect(request.get_full_path())
request.user.has_perm('ipam.add_ipaddress'): # elif new_objs is not None and '_assignip' in request.POST and len(new_objs) >= 1 and \
first_obj = new_objs[0].pk # request.user.has_perm('ipam.add_ipaddress'):
return redirect( # first_obj = new_objs[0].pk
f'/ipam/ip-addresses/add/?interface={first_obj}&return_url={self.get_return_url(request)}' # 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)) # else:
# return redirect(self.get_return_url(request))
return render(request, self.template_name, { #
'obj_type': self.queryset.model._meta.verbose_name, # return render(request, self.template_name, {
'form': form, # 'obj_type': self.queryset.model._meta.verbose_name,
'return_url': self.get_return_url(request), # 'form': form,
}) # 'return_url': self.get_return_url(request),
# })
class InterfaceEditView(generic.ObjectEditView): class InterfaceEditView(generic.ObjectEditView):
@ -2101,6 +2097,14 @@ class FrontPortCreateView(generic.ComponentCreateView):
form = forms.FrontPortCreateForm form = forms.FrontPortCreateForm
model_form = forms.FrontPortForm 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): class FrontPortEditView(generic.ObjectEditView):
queryset = FrontPort.objects.all() queryset = FrontPort.objects.all()
@ -2157,7 +2161,6 @@ class RearPortView(generic.ObjectView):
class RearPortCreateView(generic.ComponentCreateView): class RearPortCreateView(generic.ComponentCreateView):
queryset = RearPort.objects.all() queryset = RearPort.objects.all()
form = forms.RearPortCreateForm
model_form = forms.RearPortForm model_form = forms.RearPortForm
@ -2216,7 +2219,6 @@ class ModuleBayView(generic.ObjectView):
class ModuleBayCreateView(generic.ComponentCreateView): class ModuleBayCreateView(generic.ComponentCreateView):
queryset = ModuleBay.objects.all() queryset = ModuleBay.objects.all()
form = forms.ModuleBayCreateForm
model_form = forms.ModuleBayForm model_form = forms.ModuleBayForm
@ -2271,7 +2273,6 @@ class DeviceBayView(generic.ObjectView):
class DeviceBayCreateView(generic.ComponentCreateView): class DeviceBayCreateView(generic.ComponentCreateView):
queryset = DeviceBay.objects.all() queryset = DeviceBay.objects.all()
form = forms.DeviceBayCreateForm
model_form = forms.DeviceBayForm model_form = forms.DeviceBayForm
@ -2397,7 +2398,6 @@ class InventoryItemEditView(generic.ObjectEditView):
class InventoryItemCreateView(generic.ComponentCreateView): class InventoryItemCreateView(generic.ComponentCreateView):
queryset = InventoryItem.objects.all() queryset = InventoryItem.objects.all()
form = forms.InventoryItemCreateForm
model_form = forms.InventoryItemForm model_form = forms.InventoryItemForm

View File

@ -6,6 +6,7 @@ from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import ObjectDoesNotExist from django.core.exceptions import ObjectDoesNotExist
from django.db import transaction from django.db import transaction
from django.db.models import ProtectedError from django.db.models import ProtectedError
from django.forms.widgets import HiddenInput
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import get_object_or_404, redirect, render from django.shortcuts import get_object_or_404, redirect, render
from django.utils.html import escape 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.views.generic import View
from django_tables2.export import TableExport from django_tables2.export import TableExport
from dcim.forms.object_create import ComponentCreateForm
from extras.models import ExportTemplate from extras.models import ExportTemplate
from extras.signals import clear_webhooks from extras.signals import clear_webhooks
from utilities.error_handlers import handle_protectederror from utilities.error_handlers import handle_protectederror
@ -674,33 +676,46 @@ class ObjectDeleteView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
# Device/VirtualMachine components # Device/VirtualMachine components
# #
# TODO: Replace with BulkCreateView
class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View): class ComponentCreateView(GetReturnURLMixin, ObjectPermissionRequiredMixin, View):
""" """
Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine. Add one or more components (e.g. interfaces, console ports, etc.) to a Device or VirtualMachine.
""" """
queryset = None queryset = None
form = None form = ComponentCreateForm
model_form = None model_form = None
template_name = 'generic/object_edit.html' template_name = 'dcim/component_create.html'
patterned_fields = ('name', 'label')
def get_required_permission(self): def get_required_permission(self):
return get_permission_for_model(self.queryset.model, 'add') 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, { return render(request, self.template_name, {
'obj': self.queryset.model(), 'obj': self.queryset.model,
'obj_type': self.queryset.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'form': form, 'replication_form': form,
'form': model_form,
'return_url': self.get_return_url(request), 'return_url': self.get_return_url(request),
}) })
def post(self, request): def post(self, request):
logger = logging.getLogger('netbox.views.ComponentCreateView') form, model_form = self.initialize_forms(request)
form = self.form(request.POST, initial=request.GET)
self.validate_form(request, form) self.validate_form(request, form)
if form.is_valid() and not form.errors: 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 redirect(self.get_return_url(request))
return render(request, self.template_name, { return render(request, self.template_name, {
'obj': self.queryset.model,
'obj_type': self.queryset.model._meta.verbose_name, 'obj_type': self.queryset.model._meta.verbose_name,
'form': form, 'replication_form': form,
'form': model_form,
'return_url': self.get_return_url(request), '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 Validate form values and set errors on the form object as they are detected. If
no errors are found, signal success messages. no errors are found, signal success messages.
""" """
logger = logging.getLogger('netbox.views.ComponentCreateView') logger = logging.getLogger('netbox.views.ComponentCreateView')
if form.is_valid(): if form.is_valid():
new_components = [] new_components = []

View File

@ -0,0 +1,7 @@
{% extends 'generic/object_edit.html' %}
{% load form_helpers %}
{% block form %}
{% render_form replication_form %}
{{ block.super }}
{% endblock form %}

View File

@ -58,91 +58,92 @@
</h5> </h5>
<div class="card-body"> <div class="card-body">
{% if object.mark_connected %} {% if object.mark_connected %}
<span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected <span class="text-success"><i class="mdi mdi-check-bold"></i></span> Marked as connected
{% elif object.cable %} {% elif object.cable %}
<table class="table table-hover attr-table"> <table class="table table-hover attr-table">
<tr>
<th scope="row">Cable</th>
<td>
<a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td>
</tr>
{% if object.connected_endpoint %}
<tr> <tr>
<th scope="row">Device</th> <th scope="row">Cable</th>
<td> <td>
<a href="{{ object.connected_endpoint.device.get_absolute_url }}">{{ object.connected_endpoint.device }}</a> <a href="{{ object.cable.get_absolute_url }}">{{ object.cable }}</a>
<a href="{% url 'dcim:consoleport_trace' pk=object.pk %}" class="btn btn-primary btn-sm lh-1" title="Trace">
<i class="mdi mdi-transit-connection-variant" aria-hidden="true"></i>
</a>
</td> </td>
</tr> </tr>
<tr> {% if object.connected_endpoint %}
<th scope="row">Name</th> <tr>
<td> <th scope="row">Device</th>
<a href="{{ object.connected_endpoint.get_absolute_url }}">{{ object.connected_endpoint.name }}</a> <td>
</td> <a href="{{ object.connected_endpoint.device.get_absolute_url }}">{{ object.connected_endpoint.device }}</a>
</tr> </td>
<tr> </tr>
<th scope="row">Type</th> <tr>
<td>{{ object.connected_endpoint.get_type_display|placeholder }}</td> <th scope="row">Name</th>
</tr> <td>
<tr> <a href="{{ object.connected_endpoint.get_absolute_url }}">{{ object.connected_endpoint.name }}</a>
<th scope="row">Description</th> </td>
<td>{{ object.connected_endpoint.description|placeholder }}</td> </tr>
</tr> <tr>
<tr> <th scope="row">Type</th>
<th scope="row">Path Status</th> <td>{{ object.connected_endpoint.get_type_display|placeholder }}</td>
<td> </tr>
{% if object.path.is_active %} <tr>
<span class="badge bg-success">Reachable</span> <th scope="row">Description</th>
{% else %} <td>{{ object.connected_endpoint.description|placeholder }}</td>
<span class="badge bg-danger">Not Reachable</span> </tr>
{% endif %} <tr>
</td> <th scope="row">Path Status</th>
</tr> <td>
{% endif %} {% if object.path.is_active %}
</table> <span class="badge bg-success">Reachable</span>
{% else %} {% else %}
<div class="text-muted"> <span class="badge bg-danger">Not Reachable</span>
Not Connected {% endif %}
{% if perms.dcim.add_cable %} </td>
<div class="dropdown float-end"> </tr>
<button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false"> {% endif %}
<span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect </table>
</button> {% else %}
<ul class="dropdown-menu dropdown-menu-end"> <div class="text-muted">
<li> Not Connected
<a {% if perms.dcim.add_cable %}
class="dropdown-item" <div class="dropdown float-end">
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}" <button type="button" class="btn btn-primary btn-sm dropdown-toggle" data-bs-toggle="dropdown" aria-expanded="false">
> <span class="mdi mdi-ethernet-cable" aria-hidden="true"></span> Connect
Console Server Port </button>
</a> <ul class="dropdown-menu dropdown-menu-end">
</li> <li>
<li> <a
<a class="dropdown-item"
class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='console-server-port' %}?return_url={{ object.get_absolute_url }}"
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}" >
> Console Server Port
Front Port </a>
</a> </li>
</li> <li>
<li> <a
<a class="dropdown-item"
class="dropdown-item" href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='front-port' %}?return_url={{ object.get_absolute_url }}"
href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}" >
> Front Port
Rear Port </a>
</a> </li>
</li> <li>
</ul> <a
</div> class="dropdown-item"
{% endif %} href="{% url 'dcim:consoleport_connect' termination_a_id=object.pk termination_b_type='rear-port' %}?return_url={{ object.get_absolute_url }}"
</div> >
{% endif %} Rear Port
</div> </a>
</li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -143,6 +143,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -129,6 +129,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -0,0 +1,59 @@
{% load helpers %}
<div class="card">
<h5 class="card-header">Inventory Items</h5>
<div class="card-body">
<table class="table table-hover table-headings">
<thead>
<tr>
<th>Name</th>
<th>Label</th>
<th>Role</th>
<th></th>
</tr>
</thead>
<tbody>
{% for item in object.inventory_items.all %}
<tr>
<td>
<a href="{{ item.get_absolute_url }}">{{ item.name }}</a>
</td>
<td>
{{ item.label|placeholder }}
</td>
<td>
{% if item.role %}
<a href="{{ item.role.get_absolute_url }}">{{ item.role }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
<td class="text-end noprint">
{% if perms.dcim.change_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_edit' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-warning btn-sm lh-1" title="Edit">
<i class="mdi mdi-pencil" aria-hidden="true"></i>
</a>
{% endif %}
{% if perms.ipam.delete_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_delete' pk=item.pk %}?return_url={{ object.get_absolute_url }}" class="btn btn-danger btn-sm lh-1" title="Delete">
<i class="mdi mdi-trash-can-outline" aria-hidden="true"></i>
</a>
{% endif %}
</td>
</tr>
{% empty %}
<tr>
<td colspan="5" class="text-muted">None</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="card-footer text-end noprint">
{% if perms.dcim.add_inventoryitem %}
<a href="{% url 'dcim:inventoryitem_add' %}?device={{ object.device.pk }}&component_type={{ object|content_type_id }}&component_id={{ object.pk }}&return_url={{ object.get_absolute_url }}" class="btn btn-sm btn-primary">
<span class="mdi mdi-plus-thick" aria-hidden="true"></span> Add Inventory Item
</a>
{% endif %}
</div>
</div>

View File

@ -448,6 +448,7 @@
</div> </div>
{% endif %} {% endif %}
{% include 'ipam/inc/panels/fhrp_groups.html' %} {% include 'ipam/inc/panels/fhrp_groups.html' %}
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -1,16 +0,0 @@
{% extends 'generic/object_edit.html' %}
{% block buttons %}
<a class="btn btn-outline-danger" href="{{ return_url }}">Cancel</a>
{% if component_type == 'interface' and perms.ipam.add_ipaddress %}
<button type="submit" name="_assignip" class="btn btn-outline-success">
Create & Assign IP Address
</button>
{% endif %}
<button type="submit" name="_addanother" class="btn btn-outline-primary">
Create & Add Another
</button>
<button type="submit" name="_create" class="btn btn-primary">
Create
</button>
{% endblock %}

View File

@ -50,6 +50,16 @@
{% endif %} {% endif %}
</td> </td>
</tr> </tr>
<tr>
<th scope="row">Component</th>
<td>
{% if object.component %}
<a href="{{ object.component.get_absolute_url }}">{{ object.component }}</a>
{% else %}
<span class="text-muted">&mdash;</span>
{% endif %}
</td>
</tr>
<tr> <tr>
<th scope="row">Manufacturer</th> <th scope="row">Manufacturer</th>
<td> <td>

View File

@ -121,6 +121,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -131,6 +131,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -117,6 +117,7 @@
{% endif %} {% endif %}
</div> </div>
</div> </div>
{% include 'dcim/inc/panels/inventory_items.html' %}
{% plugin_right_page object %} {% plugin_right_page object %}
</div> </div>
</div> </div>

View File

@ -29,29 +29,35 @@
</div> </div>
{% endif %} {% endif %}
<form action="" method="post" enctype="multipart/form-data" class="form-object-edit"> <form action="" method="post" enctype="multipart/form-data" class="form-object-edit mt-5">
{% csrf_token %} {% csrf_token %}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{% block form %} {% block form %}
{% if form.Meta.fieldsets %} {% if form.Meta.fieldsets %}
{# Render hidden fields #}
{% for field in form.hidden_fields %}
{{ field }}
{% endfor %}
{# Render grouped fields according to Form #} {# Render grouped fields according to Form #}
{% for group, fields in form.Meta.fieldsets %} {% for group, fields in form.Meta.fieldsets %}
<div class="field-group my-5"> <div class="field-group mb-5">
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">{{ group }}</h5> <h5 class="offset-sm-3">{{ group }}</h5>
</div> </div>
{% for name in fields %} {% for name in fields %}
{% render_field form|getfield:name %} {% with field=form|getfield:name %}
{% if not field.field.widget.is_hidden %}
{% render_field field %}
{% endif %}
{% endwith %}
{% endfor %} {% endfor %}
</div> </div>
{% endfor %} {% endfor %}
{% if form.custom_fields %} {% if form.custom_fields %}
<div class="field-group my-5"> <div class="field-group mb-5">
<div class="row mb-2"> <div class="row mb-2">
<h5 class="offset-sm-3">Custom Fields</h5> <h5 class="offset-sm-3">Custom Fields</h5>
</div> </div>
@ -60,7 +66,7 @@
{% endif %} {% endif %}
{% if form.comments %} {% if form.comments %}
<div class="field-group my-5"> <div class="field-group mb-5">
<h5 class="text-center">Comments</h5> <h5 class="text-center">Comments</h5>
{% render_field form.comments %} {% render_field form.comments %}
</div> </div>
@ -68,7 +74,7 @@
{% else %} {% else %}
{# Render all fields in a single group #} {# Render all fields in a single group #}
<div class="field-group my-5"> <div class="field-group mb-5">
{% block form_fields %}{% render_form form %}{% endblock %} {% block form_fields %}{% render_form form %}{% endblock %}
</div> </div>
{% endif %} {% endif %}

View File

@ -346,11 +346,7 @@ class ExpandableNameField(forms.CharField):
if not self.help_text: if not self.help_text:
self.help_text = """ self.help_text = """
Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range
are not supported. Examples: are not supported. Example: <code>[ge,xe]-0/0/[0-9]</code>
<ul>
<li><code>[ge,xe]-0/0/[0-9]</code></li>
<li><code>e[0-3][a-d,f]</code></li>
</ul>
""" """
def to_python(self, value): def to_python(self, value):

View File

@ -275,12 +275,18 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
parent = DynamicModelChoiceField( parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
label='Parent interface' label='Parent interface',
query_params={
'virtual_machine_id': '$virtual_machine',
}
) )
bridge = DynamicModelChoiceField( bridge = DynamicModelChoiceField(
queryset=VMInterface.objects.all(), queryset=VMInterface.objects.all(),
required=False, required=False,
label='Bridged interface' label='Bridged interface',
query_params={
'virtual_machine_id': '$virtual_machine',
}
) )
vlan_group = DynamicModelChoiceField( vlan_group = DynamicModelChoiceField(
queryset=VLANGroup.objects.all(), queryset=VLANGroup.objects.all(),
@ -293,6 +299,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Untagged VLAN', label='Untagged VLAN',
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_virtualmachine': '$virtual_machine',
} }
) )
tagged_vlans = DynamicModelMultipleChoiceField( tagged_vlans = DynamicModelMultipleChoiceField(
@ -301,6 +308,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
label='Tagged VLANs', label='Tagged VLANs',
query_params={ query_params={
'group_id': '$vlan_group', 'group_id': '$vlan_group',
'available_on_virtualmachine': '$virtual_machine',
} }
) )
tags = DynamicModelMultipleChoiceField( tags = DynamicModelMultipleChoiceField(
@ -324,15 +332,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm):
help_texts = { help_texts = {
'mode': INTERFACE_MODE_HELP_TEXT, 'mode': INTERFACE_MODE_HELP_TEXT,
} }
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
# Restrict parent interface assignment by VM
self.fields['parent'].widget.add_query_param('virtual_machine_id', vm_id)
self.fields['bridge'].widget.add_query_param('virtual_machine_id', vm_id)
# Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)

View File

@ -1,81 +1,13 @@
from django import forms from django import forms
from dcim.choices import InterfaceModeChoices from utilities.forms import BootstrapMixin, ExpandableNameField
from dcim.forms.common import InterfaceCommonForm
from extras.forms import CustomFieldsMixin
from extras.models import Tag
from ipam.models import VLAN
from utilities.forms import (
add_blank_choice, BootstrapMixin, DynamicModelChoiceField, DynamicModelMultipleChoiceField, ExpandableNameField,
StaticSelect,
)
from virtualization.models import VMInterface, VirtualMachine
__all__ = ( __all__ = (
'VMInterfaceCreateForm', 'VMInterfaceCreateForm',
) )
class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): class VMInterfaceCreateForm(BootstrapMixin, forms.Form):
model = VMInterface
virtual_machine = DynamicModelChoiceField(
queryset=VirtualMachine.objects.all()
)
name_pattern = ExpandableNameField( name_pattern = ExpandableNameField(
label='Name' label='Name'
) )
enabled = forms.BooleanField(
required=False,
initial=True
)
parent = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
query_params={
'virtual_machine_id': '$virtual_machine',
}
)
bridge = DynamicModelChoiceField(
queryset=VMInterface.objects.all(),
required=False,
query_params={
'virtual_machine_id': '$virtual_machine',
}
)
mac_address = forms.CharField(
required=False,
label='MAC Address'
)
description = forms.CharField(
max_length=200,
required=False
)
mode = forms.ChoiceField(
choices=add_blank_choice(InterfaceModeChoices),
required=False,
widget=StaticSelect(),
)
untagged_vlan = DynamicModelChoiceField(
queryset=VLAN.objects.all(),
required=False
)
tagged_vlans = DynamicModelMultipleChoiceField(
queryset=VLAN.objects.all(),
required=False
)
tags = DynamicModelMultipleChoiceField(
queryset=Tag.objects.all(),
required=False
)
field_order = (
'virtual_machine', 'name_pattern', 'enabled', 'parent', 'bridge', 'mtu', 'mac_address', 'description', 'mode',
'untagged_vlan', 'tagged_vlans', 'tags'
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
vm_id = self.initial.get('virtual_machine') or self.data.get('virtual_machine')
# Limit VLAN choices by virtual machine
self.fields['untagged_vlan'].widget.add_query_param('available_on_virtualmachine', vm_id)
self.fields['tagged_vlans'].widget.add_query_param('available_on_virtualmachine', vm_id)

View File

@ -447,11 +447,11 @@ class VMInterfaceView(generic.ObjectView):
} }
# TODO: This should not use ComponentCreateView
class VMInterfaceCreateView(generic.ComponentCreateView): class VMInterfaceCreateView(generic.ComponentCreateView):
queryset = VMInterface.objects.all() queryset = VMInterface.objects.all()
form = forms.VMInterfaceCreateForm form = forms.VMInterfaceCreateForm
model_form = forms.VMInterfaceForm model_form = forms.VMInterfaceForm
patterned_fields = ('name',)
class VMInterfaceEditView(generic.ObjectEditView): class VMInterfaceEditView(generic.ObjectEditView):