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 %} - - - - - - {% if object.connected_endpoint %} +
Cable - {{ object.cable }} - - - -
- + - - - - - - - - - - - - - - - - - {% endif %} -
DeviceCable - {{ object.connected_endpoint.device }} + {{ object.cable }} + + +
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 %} -
- {% 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 %} + +
+
Inventory Items
+
+ + + + + + + + + + + {% for item in object.inventory_items.all %} + + + + + + + {% empty %} + + + + {% endfor %} + +
NameLabelRole
+ {{ item.name }} + + {{ item.label|placeholder }} + + {% if item.role %} + {{ item.role }} + {% else %} + + {% endif %} + + {% if perms.dcim.change_inventoryitem %} + + + + {% endif %} + {% if perms.ipam.delete_inventoryitem %} + + + + {% endif %} +
None
+
+ +
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 %} -
+ {% csrf_token %} - {% for field in form.hidden_fields %} - {{ field }} - {% endfor %} {% block form %} {% if form.Meta.fieldsets %} + {# Render hidden fields #} + {% for field in form.hidden_fields %} + {{ field }} + {% endfor %} + {# Render grouped fields according to Form #} {% for group, fields in form.Meta.fieldsets %} -
+
{{ group }}
{% 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 %} {% if form.custom_fields %} -
+
Custom Fields
@@ -60,7 +66,7 @@ {% endif %} {% if form.comments %} -
+
Comments
{% render_field form.comments %}
@@ -68,7 +74,7 @@ {% else %} {# Render all fields in a single group #} -
+
{% block form_fields %}{% render_form form %}{% endblock %}
{% endif %} diff --git a/netbox/utilities/forms/fields.py b/netbox/utilities/forms/fields.py index 007215b6e..0e1b42b28 100644 --- a/netbox/utilities/forms/fields.py +++ b/netbox/utilities/forms/fields.py @@ -346,11 +346,7 @@ class ExpandableNameField(forms.CharField): if not self.help_text: self.help_text = """ Alphanumeric ranges are supported for bulk creation. Mixed cases and types within a single range - are not supported. Examples: -
    -
  • [ge,xe]-0/0/[0-9]
  • -
  • e[0-3][a-d,f]
  • -
+ are not supported. Example: [ge,xe]-0/0/[0-9] """ def to_python(self, value): diff --git a/netbox/virtualization/forms/models.py b/netbox/virtualization/forms/models.py index 6fa90ea65..624c9e87f 100644 --- a/netbox/virtualization/forms/models.py +++ b/netbox/virtualization/forms/models.py @@ -275,12 +275,18 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): parent = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Parent interface' + label='Parent interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) bridge = DynamicModelChoiceField( queryset=VMInterface.objects.all(), required=False, - label='Bridged interface' + label='Bridged interface', + query_params={ + 'virtual_machine_id': '$virtual_machine', + } ) vlan_group = DynamicModelChoiceField( queryset=VLANGroup.objects.all(), @@ -293,6 +299,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Untagged VLAN', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) tagged_vlans = DynamicModelMultipleChoiceField( @@ -301,6 +308,7 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): label='Tagged VLANs', query_params={ 'group_id': '$vlan_group', + 'available_on_virtualmachine': '$virtual_machine', } ) tags = DynamicModelMultipleChoiceField( @@ -324,15 +332,3 @@ class VMInterfaceForm(InterfaceCommonForm, CustomFieldModelForm): help_texts = { '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) diff --git a/netbox/virtualization/forms/object_create.py b/netbox/virtualization/forms/object_create.py index 332334594..f275469fd 100644 --- a/netbox/virtualization/forms/object_create.py +++ b/netbox/virtualization/forms/object_create.py @@ -1,81 +1,13 @@ from django import forms -from dcim.choices import InterfaceModeChoices -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 +from utilities.forms import BootstrapMixin, ExpandableNameField __all__ = ( 'VMInterfaceCreateForm', ) -class VMInterfaceCreateForm(BootstrapMixin, CustomFieldsMixin, InterfaceCommonForm): - model = VMInterface - virtual_machine = DynamicModelChoiceField( - queryset=VirtualMachine.objects.all() - ) +class VMInterfaceCreateForm(BootstrapMixin, forms.Form): name_pattern = ExpandableNameField( 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) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 8183555bd..742d6d9ea 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -447,11 +447,11 @@ class VMInterfaceView(generic.ObjectView): } -# TODO: This should not use ComponentCreateView class VMInterfaceCreateView(generic.ComponentCreateView): queryset = VMInterface.objects.all() form = forms.VMInterfaceCreateForm model_form = forms.VMInterfaceForm + patterned_fields = ('name',) class VMInterfaceEditView(generic.ObjectEditView):