diff --git a/docs/configuration/optional-settings.md b/docs/configuration/optional-settings.md index 80715b7ec..503ed1954 100644 --- a/docs/configuration/optional-settings.md +++ b/docs/configuration/optional-settings.md @@ -344,7 +344,7 @@ When determining the primary IP address for a device, IPv6 is preferred over IPv Default: `False` -NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authenitcation will still take effect as a fallback.) +NetBox can be configured to support remote user authentication by inferring user authentication from an HTTP header set by the HTTP reverse proxy (e.g. nginx or Apache). Set this to `True` to enable this functionality. (Local authentication will still take effect as a fallback.) --- diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index b9b68be1b..5237e617e 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -1,13 +1,15 @@ # NetBox Installation -This section of the documentation discusses installing and configuring the NetBox application. Begin by installing all system packages required by NetBox and its dependencies: +This section of the documentation discusses installing and configuring the NetBox application itself. ## Install System Packages +Begin by installing all system packages required by NetBox and its dependencies. Note that beginning with NetBox v2.8, Python 3.6 or later is required. + ### Ubuntu ```no-highlight -# apt-get install -y python3 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev +# apt-get install -y python3.6 python3-pip python3-venv python3-dev build-essential libxml2-dev libxslt1-dev libffi-dev libpq-dev libssl-dev zlib1g-dev ``` ### CentOS diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index 83cd59d1d..c34fef954 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -4,6 +4,9 @@ Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../../release-notes/) that have been published since your current version was released. Although the upgrade process typically does not involve additional work, certain releases may introduce breaking or backward-incompatible changes. These are called out in the release notes under the version in which the change went into effect. +!!! note + Beginning with version 2.8, NetBox requires Python 3.6 or later. + ## Install the Latest Code As with the initial installation, you can upgrade NetBox by either downloading the latest release package or by cloning the `master` branch of the git repository. diff --git a/docs/media/installation/netbox_application_stack.png b/docs/media/installation/netbox_application_stack.png index 56de2070a..e86344900 100644 Binary files a/docs/media/installation/netbox_application_stack.png and b/docs/media/installation/netbox_application_stack.png differ diff --git a/docs/models/dcim/rackgroup.md b/docs/models/dcim/rackgroup.md index ad9df4eef..f5b2428e6 100644 --- a/docs/models/dcim/rackgroup.md +++ b/docs/models/dcim/rackgroup.md @@ -2,6 +2,6 @@ Racks can be arranged into groups. As with sites, how you choose to designate rack groups will depend on the nature of your organization. For example, if each site represents a campus, each group might represent a building within a campus. If each site represents a building, each rack group might equate to a floor or room. -Each rack group must be assigned to a parent site. Hierarchical recursion of rack groups is not currently supported. +Each rack group must be assigned to a parent site, and rack groups may optionally be nested to achieve a multi-level hierarchy. The name and facility ID of each rack within a group must be unique. (Racks not assigned to the same rack group may have identical names and/or facility IDs.) diff --git a/docs/models/tenancy/tenantgroup.md b/docs/models/tenancy/tenantgroup.md index 48d9f4b6e..a2ed7e324 100644 --- a/docs/models/tenancy/tenantgroup.md +++ b/docs/models/tenancy/tenantgroup.md @@ -1,3 +1,5 @@ # Tenant Groups Tenants can be organized by custom groups. For instance, you might create one group called "Customers" and one called "Acquisitions." The assignment of tenants to groups is optional. + +Tenant groups may be nested to achieve a multi-level hierarchy. For example, you might have a group called "Customers" containing subgroups of individual tenants grouped by product or account team. diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index c1392e99d..e6eabf8ca 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,7 +1,45 @@ # NetBox v2.8 +## v2.8.1 (2020-04-23) + +### Notes + +In accordance with the fix in [#4459](https://github.com/netbox-community/netbox/issues/4459), users that are experiencing invalid nested data with +regions, rack groups, or tenant groups can perform a one-time operation using the NetBox shell to rebuild the correct nested relationships after upgrading: + +```text +$ python netbox/manage.py nbshell +### NetBox interactive shell (localhost) +### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1 +### lsmodels() will show available models. Use help() for more info. +>>> Region.objects.rebuild() +>>> RackGroup.objects.rebuild() +>>> TenantGroup.objects.rebuild() +``` + +### Enhancements + +* [#4464](https://github.com/netbox-community/netbox/issues/4464) - Add 21-inch rack width (ETSI) + +### Bug Fixes + +* [#2994](https://github.com/netbox-community/netbox/issues/2994) - Prevent modifying termination points of existing cable to ensure end-to-end path integrity +* [#3356](https://github.com/netbox-community/netbox/issues/3356) - Correct Swagger schema specification for the available prefixes/IPs API endpoints +* [#4139](https://github.com/netbox-community/netbox/issues/4139) - Enable assigning all relevant attributes during bulk device/VM component creation +* [#4336](https://github.com/netbox-community/netbox/issues/4336) - Ensure interfaces without a subinterface ID are ordered before subinterface zero +* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in Swagger schema +* [#4388](https://github.com/netbox-community/netbox/issues/4388) - Fix detection of connected endpoints when connecting rear ports +* [#4459](https://github.com/netbox-community/netbox/issues/4459) - Fix caching issue resulting in erroneous nested data for regions, rack groups, and tenant groups +* [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view +* [#4496](https://github.com/netbox-community/netbox/issues/4496) - Fix exception when validating certain models via REST API +* [#4510](https://github.com/netbox-community/netbox/issues/4510) - Enforce address family for device primary IPv4/v6 addresses + +--- + ## v2.8.0 (2020-04-13) +**NOTE:** Beginning with release 2.8.0, NetBox requires Python 3.6 or later. + ### New Features (Beta) This releases introduces two new features in beta status. While they are expected to be functional, their precise implementation is subject to change during the v2.8 release cycle. It is recommended to wait until NetBox v2.9 to deploy them in production. @@ -35,7 +73,7 @@ For NetBox plugins to be recognized, they must be installed and added by name to * [#1754](https://github.com/netbox-community/netbox/issues/1754) - Added support for nested rack groups * [#3939](https://github.com/netbox-community/netbox/issues/3939) - Added support for nested tenant groups * [#4078](https://github.com/netbox-community/netbox/issues/4078) - Standardized description fields across all models -* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](../configuration/optional-settings.md#logging)) +* [#4195](https://github.com/netbox-community/netbox/issues/4195) - Enabled application logging (see [logging configuration](https://netbox.readthedocs.io/en/stable/configuration/optional-settings/#logging)) ### Bug Fixes diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index 04d125b21..9ac58dc3a 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -143,8 +143,7 @@ class RackSerializer(TaggitSerializer, CustomFieldModelSerializer): # Validate uniqueness of (group, facility_id) since we omitted the automatically-created validator from Meta. if data.get('facility_id', None): validator = UniqueTogetherValidator(queryset=Rack.objects.all(), fields=('group', 'facility_id')) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) @@ -395,8 +394,7 @@ class DeviceSerializer(TaggitSerializer, CustomFieldModelSerializer): # Validate uniqueness of (rack, position, face) since we omitted the automatically-created validator from Meta. if data.get('rack') and data.get('position') and data.get('face'): validator = UniqueTogetherValidator(queryset=Device.objects.all(), fields=('rack', 'position', 'face')) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index 2e08283ff..9c8fe12de 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -48,7 +48,7 @@ class CableTraceMixin(object): # Initialize the path array path = [] - for near_end, cable, far_end in obj.trace(): + for near_end, cable, far_end in obj.trace()[0]: # Serialize each object serializer_a = get_serializer_for_model(near_end, prefix='Nested') diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f1f60b753..32802c61f 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -57,11 +57,13 @@ class RackWidthChoices(ChoiceSet): WIDTH_10IN = 10 WIDTH_19IN = 19 + WIDTH_21IN = 21 WIDTH_23IN = 23 CHOICES = ( (WIDTH_10IN, '10 inches'), (WIDTH_19IN, '19 inches'), + (WIDTH_21IN, '21 inches'), (WIDTH_23IN, '23 inches'), ) diff --git a/netbox/dcim/exceptions.py b/netbox/dcim/exceptions.py index e788c9b5f..18e42318b 100644 --- a/netbox/dcim/exceptions.py +++ b/netbox/dcim/exceptions.py @@ -3,3 +3,12 @@ class LoopDetected(Exception): A loop has been detected while tracing a cable path. """ pass + + +class CableTraceSplit(Exception): + """ + A cable trace cannot be completed because a RearPort maps to multiple FrontPorts and + we don't know which one to follow. + """ + def __init__(self, termination, *args, **kwargs): + self.termination = termination diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 48b0de903..29710971e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -23,8 +23,9 @@ from tenancy.models import Tenant, TenantGroup from utilities.forms import ( APISelect, APISelectMultiple, add_blank_choice, ArrayFieldSelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ColorSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, - DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, JSONField, SelectWithPK, - SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, BOOLEAN_WITH_BLANK_CHOICES, + DynamicModelMultipleChoiceField, ExpandableNameField, FlexibleModelChoiceField, form_from_model, JSONField, + SelectWithPK, SmallTextarea, SlugField, StaticSelect2, StaticSelect2Multiple, TagFilterField, + BOOLEAN_WITH_BLANK_CHOICES, ) from virtualization.models import Cluster, ClusterGroup, VirtualMachine from .choices import * @@ -2298,30 +2299,10 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): label='Name' ) - -class DeviceBulkAddInterfaceForm(DeviceBulkAddComponentForm): - type = forms.ChoiceField( - choices=InterfaceTypeChoices, - widget=StaticSelect2() - ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - mgmt_only = forms.BooleanField( - required=False, - label='Management only' - ) - description = forms.CharField( - max_length=100, - required=False - ) + def clean_tags(self): + # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we + # must first convert the list of tags to a string. + return ','.join(self.cleaned_data.get('tags')) # @@ -2375,20 +2356,23 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): ) -class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class ConsolePortBulkCreateForm( + form_from_model(ConsolePort, ['type', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass + + +class ConsolePortBulkEditForm( + form_from_model(ConsolePort, ['type', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect2() - ) - description = forms.CharField( - max_length=100, - required=False - ) class Meta: nullable_fields = ( @@ -2462,20 +2446,23 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): ) -class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class ConsoleServerPortBulkCreateForm( + form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass + + +class ConsoleServerPortBulkEditForm( + form_from_model(ConsoleServerPort, ['type', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ChoiceField( - choices=add_blank_choice(ConsolePortTypeChoices), - required=False, - widget=StaticSelect2() - ) - description = forms.CharField( - max_length=100, - required=False - ) class Meta: nullable_fields = [ @@ -2573,30 +2560,23 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): ) -class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class PowerPortBulkCreateForm( + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass + + +class PowerPortBulkEditForm( + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerPortTypeChoices), - required=False, - widget=StaticSelect2() - ) - 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" - ) - description = forms.CharField( - max_length=100, - required=False - ) class Meta: nullable_fields = ( @@ -2700,6 +2680,61 @@ class PowerOutletCreateForm(BootstrapMixin, forms.Form): self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) +class PowerOutletBulkCreateForm( + form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass + + +class PowerOutletBulkEditForm( + form_from_model(PowerOutlet, ['type', 'feed_leg', 'power_port', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput() + ) + device = forms.ModelChoiceField( + queryset=Device.objects.all(), + required=False, + disabled=True, + widget=forms.HiddenInput() + ) + + class Meta: + nullable_fields = [ + 'type', 'feed_leg', 'power_port', 'description', + ] + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit power_port queryset to PowerPorts which belong to the parent Device + if 'device' in self.initial: + device = Device.objects.filter(pk=self.initial['device']).first() + self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) + else: + self.fields['power_port'].choices = () + self.fields['power_port'].widget.attrs['disabled'] = True + + +class PowerOutletBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class PowerOutletBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=PowerOutlet.objects.all(), + widget=forms.MultipleHiddenInput + ) + + class PowerOutletCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2750,65 +2785,6 @@ class PowerOutletCSVForm(forms.ModelForm): self.fields['power_port'].queryset = PowerPort.objects.none() -class PowerOutletBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput() - ) - device = forms.ModelChoiceField( - queryset=Device.objects.all(), - required=False, - disabled=True, - widget=forms.HiddenInput() - ) - type = forms.ChoiceField( - choices=add_blank_choice(PowerOutletTypeChoices), - required=False - ) - feed_leg = forms.ChoiceField( - choices=add_blank_choice(PowerOutletFeedLegChoices), - required=False, - ) - power_port = forms.ModelChoiceField( - queryset=PowerPort.objects.all(), - required=False - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'type', 'feed_leg', 'power_port', 'description', - ] - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit power_port queryset to PowerPorts which belong to the parent Device - if 'device' in self.initial: - device = Device.objects.filter(pk=self.initial['device']).first() - self.fields['power_port'].queryset = PowerPort.objects.filter(device=device) - else: - self.fields['power_port'].choices = () - self.fields['power_port'].widget.attrs['disabled'] = True - - -class PowerOutletBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - -class PowerOutletBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=PowerOutlet.objects.all(), - widget=forms.MultipleHiddenInput - ) - - # # Interfaces # @@ -2985,74 +2961,19 @@ class InterfaceCreateForm(BootstrapMixin, InterfaceCommonForm, forms.Form): self.fields['tagged_vlans'].widget.add_additional_query_param('site_id', device.site.pk) -class InterfaceCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } - ) - virtual_machine = FlexibleModelChoiceField( - queryset=VirtualMachine.objects.all(), - required=False, - to_field_name='name', - help_text='Name or ID of virtual machine', - error_messages={ - 'invalid_choice': 'Virtual machine not found.', - } - ) - lag = FlexibleModelChoiceField( - queryset=Interface.objects.all(), - required=False, - to_field_name='name', - help_text='Name or ID of LAG interface', - error_messages={ - 'invalid_choice': 'LAG interface not found.', - } - ) - type = CSVChoiceField( - choices=InterfaceTypeChoices, - ) - mode = CSVChoiceField( - choices=InterfaceModeChoices, - required=False, - ) - - class Meta: - model = Interface - fields = Interface.csv_headers - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit LAG choices to interfaces belonging to this device (or VC master) - if self.is_bound and 'device' in self.data: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - device = self.instance.device - - if device: - self.fields['lag'].queryset = Interface.objects.filter( - device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG - ) - else: - self.fields['lag'].queryset = Interface.objects.none() - - def clean_enabled(self): - # Make sure enabled is True when it's not included in the uploaded data - if 'enabled' not in self.data: - return True - else: - return self.cleaned_data['enabled'] +class InterfaceBulkCreateForm( + form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass -class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class InterfaceBulkEditForm( + form_from_model(Interface, ['type', 'enabled', 'lag', 'mac_address', 'mtu', 'mgmt_only', 'description', 'mode']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): pk = forms.ModelMultipleChoiceField( queryset=Interface.objects.all(), widget=forms.MultipleHiddenInput() @@ -3063,45 +2984,6 @@ class InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): disabled=True, widget=forms.HiddenInput() ) - type = forms.ChoiceField( - choices=add_blank_choice(InterfaceTypeChoices), - required=False, - widget=StaticSelect2() - ) - enabled = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect() - ) - lag = forms.ModelChoiceField( - queryset=Interface.objects.all(), - required=False, - label='Parent LAG', - widget=StaticSelect2() - ) - mac_address = forms.CharField( - required=False, - label='MAC Address' - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - mgmt_only = forms.NullBooleanField( - required=False, - widget=BulkEditNullBooleanSelect(), - label='Management only' - ) - description = forms.CharField( - max_length=100, - required=False - ) - mode = forms.ChoiceField( - choices=add_blank_choice(InterfaceModeChoices), - required=False, - widget=StaticSelect2() - ) untagged_vlan = DynamicModelChoiceField( queryset=VLAN.objects.all(), required=False, @@ -3175,6 +3057,73 @@ class InterfaceBulkDisconnectForm(ConfirmationForm): ) +class InterfaceCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + virtual_machine = FlexibleModelChoiceField( + queryset=VirtualMachine.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of virtual machine', + error_messages={ + 'invalid_choice': 'Virtual machine not found.', + } + ) + lag = FlexibleModelChoiceField( + queryset=Interface.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of LAG interface', + error_messages={ + 'invalid_choice': 'LAG interface not found.', + } + ) + type = CSVChoiceField( + choices=InterfaceTypeChoices, + ) + mode = CSVChoiceField( + choices=InterfaceModeChoices, + required=False, + ) + + class Meta: + model = Interface + fields = Interface.csv_headers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit LAG choices to interfaces belonging to this device (or VC master) + if self.is_bound and 'device' in self.data: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + device = self.instance.device + + if device: + self.fields['lag'].queryset = Interface.objects.filter( + device__in=[device, device.get_vc_master()], type=InterfaceTypeChoices.TYPE_LAG + ) + else: + self.fields['lag'].queryset = Interface.objects.none() + + def clean_enabled(self): + # Make sure enabled is True when it's not included in the uploaded data + if 'enabled' not in self.data: + return True + else: + return self.cleaned_data['enabled'] + + # # Front pass-through ports # @@ -3283,6 +3232,44 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form): } +# class FrontPortBulkCreateForm( +# form_from_model(FrontPort, ['type', 'description', 'tags']), +# DeviceBulkAddComponentForm +# ): +# pass + + +class FrontPortBulkEditForm( + form_from_model(FrontPort, ['type', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = [ + 'description', + ] + + +class FrontPortBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + +class FrontPortBulkDisconnectForm(ConfirmationForm): + pk = forms.ModelMultipleChoiceField( + queryset=FrontPort.objects.all(), + widget=forms.MultipleHiddenInput + ) + + class FrontPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -3331,41 +3318,6 @@ class FrontPortCSVForm(forms.ModelForm): self.fields['rear_port'].queryset = RearPort.objects.none() -class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput() - ) - type = forms.ChoiceField( - choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect2() - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = [ - 'description', - ] - - -class FrontPortBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - -class FrontPortBulkDisconnectForm(ConfirmationForm): - pk = forms.ModelMultipleChoiceField( - queryset=FrontPort.objects.all(), - widget=forms.MultipleHiddenInput - ) - - # # Rear pass-through ports # @@ -3418,38 +3370,23 @@ class RearPortCreateForm(BootstrapMixin, forms.Form): ) -class RearPortCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } - ) - type = CSVChoiceField( - choices=PortTypeChoices, - ) - - class Meta: - model = RearPort - fields = RearPort.csv_headers +class RearPortBulkCreateForm( + form_from_model(RearPort, ['type', 'positions', 'description', 'tags']), + DeviceBulkAddComponentForm +): + pass -class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class RearPortBulkEditForm( + form_from_model(RearPort, ['type', 'description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), widget=forms.MultipleHiddenInput() ) - type = forms.ChoiceField( - choices=add_blank_choice(PortTypeChoices), - required=False, - widget=StaticSelect2() - ) - description = forms.CharField( - max_length=100, - required=False - ) class Meta: nullable_fields = [ @@ -3471,6 +3408,164 @@ class RearPortBulkDisconnectForm(ConfirmationForm): ) +class RearPortCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + type = CSVChoiceField( + choices=PortTypeChoices, + ) + + class Meta: + model = RearPort + fields = RearPort.csv_headers + + +# +# Device bays +# + +class DeviceBayFilterForm(DeviceComponentFilterForm): + model = DeviceBay + tag = TagFilterField(model) + + +class DeviceBayForm(BootstrapMixin, forms.ModelForm): + tags = TagField( + required=False + ) + + class Meta: + model = DeviceBay + fields = [ + 'device', 'name', 'description', 'tags', + ] + widgets = { + 'device': forms.HiddenInput(), + } + + +class DeviceBayCreateForm(BootstrapMixin, forms.Form): + device = DynamicModelChoiceField( + queryset=Device.objects.prefetch_related('device_type__manufacturer') + ) + name_pattern = ExpandableNameField( + label='Name' + ) + tags = TagField( + required=False + ) + + +class PopulateDeviceBayForm(BootstrapMixin, forms.Form): + installed_device = forms.ModelChoiceField( + queryset=Device.objects.all(), + label='Child Device', + help_text="Child devices must first be created and assigned to the site/rack of the parent device.", + widget=StaticSelect2(), + ) + + def __init__(self, device_bay, *args, **kwargs): + + super().__init__(*args, **kwargs) + + self.fields['installed_device'].queryset = Device.objects.filter( + site=device_bay.device.site, + rack=device_bay.device.rack, + parent_bay__isnull=True, + device_type__u_height=0, + device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD + ).exclude(pk=device_bay.device.pk) + + +class DeviceBayBulkCreateForm( + form_from_model(DeviceBay, ['description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False + ) + + +class DeviceBayBulkEditForm( + form_from_model(DeviceBay, ['description']), + BootstrapMixin, + AddRemoveTagsForm, + BulkEditForm +): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + class Meta: + nullable_fields = ( + 'description', + ) + + +class DeviceBayBulkRenameForm(BulkRenameForm): + pk = forms.ModelMultipleChoiceField( + queryset=DeviceBay.objects.all(), + widget=forms.MultipleHiddenInput() + ) + + +class DeviceBayCSVForm(forms.ModelForm): + device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Device not found.', + } + ) + installed_device = FlexibleModelChoiceField( + queryset=Device.objects.all(), + required=False, + to_field_name='name', + help_text='Name or ID of device', + error_messages={ + 'invalid_choice': 'Child device not found.', + } + ) + + class Meta: + model = DeviceBay + fields = DeviceBay.csv_headers + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + + # Limit installed device choices to devices of the correct type and location + if self.is_bound: + try: + device = self.fields['device'].to_python(self.data['device']) + except forms.ValidationError: + device = None + else: + try: + device = self.instance.device + except Device.DoesNotExist: + device = None + + if device: + self.fields['installed_device'].queryset = Device.objects.filter( + site=device.site, + rack=device.rack, + parent_bay__isnull=True, + device_type__u_height=0, + device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD + ).exclude(pk=device.pk) + else: + self.fields['installed_device'].queryset = Interface.objects.none() + + # # Cables # @@ -3954,136 +4049,6 @@ class CableFilterForm(BootstrapMixin, forms.Form): ) -# -# Device bays -# - -class DeviceBayFilterForm(DeviceComponentFilterForm): - model = DeviceBay - tag = TagFilterField(model) - - -class DeviceBayForm(BootstrapMixin, forms.ModelForm): - tags = TagField( - required=False - ) - - class Meta: - model = DeviceBay - fields = [ - 'device', 'name', 'description', 'tags', - ] - widgets = { - 'device': forms.HiddenInput(), - } - - -class DeviceBayCreateForm(BootstrapMixin, forms.Form): - device = DynamicModelChoiceField( - queryset=Device.objects.prefetch_related('device_type__manufacturer') - ) - name_pattern = ExpandableNameField( - label='Name' - ) - tags = TagField( - required=False - ) - - -class PopulateDeviceBayForm(BootstrapMixin, forms.Form): - installed_device = forms.ModelChoiceField( - queryset=Device.objects.all(), - label='Child Device', - help_text="Child devices must first be created and assigned to the site/rack of the parent device.", - widget=StaticSelect2(), - ) - - def __init__(self, device_bay, *args, **kwargs): - - super().__init__(*args, **kwargs) - - self.fields['installed_device'].queryset = Device.objects.filter( - site=device_bay.device.site, - rack=device_bay.device.rack, - parent_bay__isnull=True, - device_type__u_height=0, - device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD - ).exclude(pk=device_bay.device.pk) - - -class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - description = forms.CharField( - max_length=100, - required=False - ) - - class Meta: - nullable_fields = ( - 'description', - ) - - -class DeviceBayCSVForm(forms.ModelForm): - device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Device not found.', - } - ) - installed_device = FlexibleModelChoiceField( - queryset=Device.objects.all(), - required=False, - to_field_name='name', - help_text='Name or ID of device', - error_messages={ - 'invalid_choice': 'Child device not found.', - } - ) - - class Meta: - model = DeviceBay - fields = DeviceBay.csv_headers - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - - # Limit installed device choices to devices of the correct type and location - if self.is_bound: - try: - device = self.fields['device'].to_python(self.data['device']) - except forms.ValidationError: - device = None - else: - try: - device = self.instance.device - except Device.DoesNotExist: - device = None - - if device: - self.fields['installed_device'].queryset = Device.objects.filter( - site=device.site, - rack=device.rack, - parent_bay__isnull=True, - device_type__u_height=0, - device_type__subdevice_role=SubdeviceRoleChoices.ROLE_CHILD - ).exclude(pk=device.pk) - else: - self.fields['installed_device'].queryset = Interface.objects.none() - - -class DeviceBayBulkRenameForm(BulkRenameForm): - pk = forms.ModelMultipleChoiceField( - queryset=DeviceBay.objects.all(), - widget=forms.MultipleHiddenInput() - ) - - # # Connections # diff --git a/netbox/dcim/migrations/0105_interface_name_collation.py b/netbox/dcim/migrations/0105_interface_name_collation.py new file mode 100644 index 000000000..3079cf5cd --- /dev/null +++ b/netbox/dcim/migrations/0105_interface_name_collation.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.5 on 2020-04-21 20:13 + +from django.db import migrations +import utilities.query_functions + + +class Migration(migrations.Migration): + + dependencies = [ + ('dcim', '0104_correct_infiniband_types'), + ] + + operations = [ + migrations.AlterModelOptions( + name='interface', + options={'ordering': ('device', utilities.query_functions.CollateAsChar('_name'))}, + ), + ] diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 144bcc28a..096065cab 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1514,24 +1514,30 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): # Validate primary IP addresses vc_interfaces = self.vc_interfaces.all() if self.primary_ip4: + if self.primary_ip4.family != 4: + raise ValidationError({ + 'primary_ip4': f"{self.primary_ip4} is not an IPv4 address." + }) if self.primary_ip4.interface in vc_interfaces: pass elif self.primary_ip4.nat_inside is not None and self.primary_ip4.nat_inside.interface in vc_interfaces: pass else: raise ValidationError({ - 'primary_ip4': "The specified IP address ({}) is not assigned to this device.".format( - self.primary_ip4), + 'primary_ip4': f"The specified IP address ({self.primary_ip4}) is not assigned to this device." }) if self.primary_ip6: + if self.primary_ip6.family != 6: + raise ValidationError({ + 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address." + }) if self.primary_ip6.interface in vc_interfaces: pass elif self.primary_ip6.nat_inside is not None and self.primary_ip6.nat_inside.interface in vc_interfaces: pass else: raise ValidationError({ - 'primary_ip6': "The specified IP address ({}) is not assigned to this device.".format( - self.primary_ip6), + 'primary_ip6': f"The specified IP address ({self.primary_ip6}) is not assigned to this device." }) # Validate manufacturer/platform @@ -2070,6 +2076,20 @@ class Cable(ChangeLoggedModel): # A copy of the PK to be used by __str__ in case the object is deleted self._pk = self.pk + @classmethod + def from_db(cls, db, field_names, values): + """ + Cache the original A and B terminations of existing Cable instances for later reference inside clean(). + """ + instance = super().from_db(db, field_names, values) + + instance._orig_termination_a_type = instance.termination_a_type + instance._orig_termination_a_id = instance.termination_a_id + instance._orig_termination_b_type = instance.termination_b_type + instance._orig_termination_b_id = instance.termination_b_id + + return instance + def __str__(self): return self.label or '#{}'.format(self._pk) @@ -2098,6 +2118,24 @@ class Cable(ChangeLoggedModel): 'termination_b': 'Invalid ID for type {}'.format(self.termination_b_type) }) + # If editing an existing Cable instance, check that neither termination has been modified. + if self.pk: + err_msg = 'Cable termination points may not be modified. Delete and recreate the cable instead.' + if ( + self.termination_a_type != self._orig_termination_a_type or + self.termination_a_id != self._orig_termination_a_id + ): + raise ValidationError({ + 'termination_a': err_msg + }) + if ( + self.termination_b_type != self._orig_termination_b_type or + self.termination_b_id != self._orig_termination_b_id + ): + raise ValidationError({ + 'termination_b': err_msg + }) + type_a = self.termination_a_type.model type_b = self.termination_b_type.model @@ -2205,26 +2243,3 @@ class Cable(ChangeLoggedModel): if self.termination_a is None: return return COMPATIBLE_TERMINATION_TYPES[self.termination_a._meta.model_name] - - def get_path_endpoints(self): - """ - Traverse both ends of a cable path and return its connected endpoints. Note that one or both endpoints may be - None. - """ - a_path = self.termination_b.trace() - b_path = self.termination_a.trace() - - # Determine overall path status (connected or planned) - if self.status == CableStatusChoices.STATUS_CONNECTED: - path_status = True - for segment in a_path[1:] + b_path[1:]: - if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED: - path_status = False - break - else: - path_status = False - - a_endpoint = a_path[-1][2] - b_endpoint = b_path[-1][2] - - return a_endpoint, b_endpoint, path_status diff --git a/netbox/dcim/models/device_components.py b/netbox/dcim/models/device_components.py index 3e615b283..8c79d89d8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,11 +10,13 @@ from taggit.managers import TaggableManager from dcim.choices import * from dcim.constants import * +from dcim.exceptions import CableTraceSplit from dcim.fields import MACAddressField from extras.models import ObjectChange, TaggedItem from extras.utils import extras_features from utilities.fields import NaturalOrderingField from utilities.ordering import naturalize_interface +from utilities.query_functions import CollateAsChar from utilities.utils import serialize_object from virtualization.choices import VMInterfaceTypeChoices @@ -91,7 +93,13 @@ class CableTermination(models.Model): def trace(self): """ - Return a list representing a complete cable path, with each individual segment represented as a three-tuple: + Return two items: the traceable portion of a cable path, and the termination points where it splits (if any). + This occurs when the trace is initiated from a midpoint along a path which traverses a RearPort. In cases where + the originating endpoint is unknown, it is not possible to know which corresponding FrontPort to follow. + + The path is a list representing a complete cable path, with each individual segment represented as a + three-tuple: + [ (termination A, cable, termination B), (termination C, cable, termination D), @@ -117,10 +125,7 @@ class CableTermination(models.Model): # Can't map to a FrontPort without a position if not position_stack: - # TODO: This behavior is broken. We need a mechanism by which to return all FrontPorts mapped - # to a given RearPort so that we can update end-to-end paths when a cable is created/deleted. - # For now, we're maintaining the current behavior of tracing only to the first FrontPort. - position_stack.append(1) + raise CableTraceSplit(termination) position = position_stack.pop() @@ -159,12 +164,12 @@ class CableTermination(models.Model): if not endpoint.cable: path.append((endpoint, None, None)) logger.debug("No cable connected") - return path + return path, None # Check for loops if endpoint.cable in [segment[1] for segment in path]: logger.debug("Loop detected!") - return path + return path, None # Record the current segment in the path far_end = endpoint.get_cable_peer() @@ -174,9 +179,13 @@ class CableTermination(models.Model): )) # Get the peer port of the far end termination - endpoint = get_peer_port(far_end) + try: + endpoint = get_peer_port(far_end) + except CableTraceSplit as e: + return path, e.termination.frontports.all() + if endpoint is None: - return path + return path, None def get_cable_peer(self): if self.cable is None: @@ -186,6 +195,23 @@ class CableTermination(models.Model): if self._cabled_as_b.exists(): return self.cable.termination_a + def get_path_endpoints(self): + """ + Return all endpoints of paths which traverse this object. + """ + endpoints = [] + + # Get the far end of the last path segment + path, split_ends = self.trace() + endpoint = path[-1][2] + if split_ends is not None: + for termination in split_ends: + endpoints.extend(termination.get_path_endpoints()) + elif endpoint is not None: + endpoints.append(endpoint) + + return endpoints + # # Console ports @@ -651,7 +677,7 @@ class Interface(CableTermination, ComponentModel): class Meta: # TODO: ordering and unique_together should include virtual_machine - ordering = ('device', '_name') + ordering = ('device', CollateAsChar('_name')) unique_together = ('device', 'name') def __str__(self): diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 4ea09655f..c94ecf61e 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -3,6 +3,7 @@ import logging from django.db.models.signals import post_save, pre_delete from django.dispatch import receiver +from .choices import CableStatusChoices from .models import Cable, Device, VirtualChassis @@ -48,16 +49,28 @@ def update_connected_endpoints(instance, **kwargs): instance.termination_b.cable = instance instance.termination_b.save() - # Check if this Cable has formed a complete path. If so, update both endpoints. - endpoint_a, endpoint_b, path_status = instance.get_path_endpoints() - if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): - logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) - endpoint_a.connected_endpoint = endpoint_b - endpoint_a.connection_status = path_status - endpoint_a.save() - endpoint_b.connected_endpoint = endpoint_a - endpoint_b.connection_status = path_status - endpoint_b.save() + # Update any endpoints for this Cable. + endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() + for endpoint in endpoints: + path, split_ends = endpoint.trace() + # Determine overall path status (connected or planned) + path_status = True + for segment in path: + if segment[1] is None or segment[1].status != CableStatusChoices.STATUS_CONNECTED: + path_status = False + break + + endpoint_a = path[0][0] + endpoint_b = path[-1][2] + + if getattr(endpoint_a, 'is_path_endpoint', False) and getattr(endpoint_b, 'is_path_endpoint', False): + logger.debug("Updating path endpoints: {} <---> {}".format(endpoint_a, endpoint_b)) + endpoint_a.connected_endpoint = endpoint_b + endpoint_a.connection_status = path_status + endpoint_a.save() + endpoint_b.connected_endpoint = endpoint_a + endpoint_b.connection_status = path_status + endpoint_b.save() @receiver(pre_delete, sender=Cable) @@ -67,7 +80,7 @@ def nullify_connected_endpoints(instance, **kwargs): """ logger = logging.getLogger('netbox.dcim.cable') - endpoint_a, endpoint_b, _ = instance.get_path_endpoints() + endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() # Disassociate the Cable from its termination points if instance.termination_a is not None: @@ -79,12 +92,10 @@ def nullify_connected_endpoints(instance, **kwargs): instance.termination_b.cable = None instance.termination_b.save() - # If this Cable was part of a complete path, tear it down - if hasattr(endpoint_a, 'connected_endpoint') and hasattr(endpoint_b, 'connected_endpoint'): - logger.debug("Tearing down path ({} <---> {})".format(endpoint_a, endpoint_b)) - endpoint_a.connected_endpoint = None - endpoint_a.connection_status = None - endpoint_a.save() - endpoint_b.connected_endpoint = None - endpoint_b.connection_status = None - endpoint_b.save() + # If this Cable was part of any complete end-to-end paths, tear them down. + for endpoint in endpoints: + logger.debug(f"Removing path information for {endpoint}") + if hasattr(endpoint, 'connected_endpoint'): + endpoint.connected_endpoint = None + endpoint.connection_status = None + endpoint.save() diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 26cf3d1c2..d45d972f8 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -582,6 +582,7 @@ class RackTest(APITestCase): data = { 'name': 'Test Rack 4', + 'facility_id': '1234', 'site': self.site1.pk, 'group': self.rackgroup1.pk, 'role': self.rackrole1.pk, @@ -1815,6 +1816,7 @@ class DeviceTest(APITestCase): self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.site2 = Site.objects.create(name='Test Site 2', slug='test-site-2') + self.rack1 = Rack.objects.create(name='Test Rack 1', site=self.site1, u_height=48) manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') self.devicetype1 = DeviceType.objects.create( manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1' @@ -1920,6 +1922,9 @@ class DeviceTest(APITestCase): 'device_role': self.devicerole1.pk, 'name': 'Test Device 4', 'site': self.site1.pk, + 'rack': self.rack1.pk, + 'face': DeviceFaceChoices.FACE_FRONT, + 'position': 1, 'cluster': self.cluster1.pk, } diff --git a/netbox/dcim/tests/test_models.py b/netbox/dcim/tests/test_models.py index 303980630..7be9ef6e4 100644 --- a/netbox/dcim/tests/test_models.py +++ b/netbox/dcim/tests/test_models.py @@ -549,12 +549,21 @@ class CablePathTestCase(TestCase): self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) - def test_connection_via_patch(self): + def test_connections_via_patch(self): """ - 1 2 3 - [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Device 2] - Iface1 FP1 RP1 RP1 FP1 Iface1 + Test two connections via patched rear ports: + Device 1 <---> Device 2 + Device 3 <---> Device 4 + 1 2 + [Device 1] -----------+ +----------- [Device 2] + Iface1 | | Iface1 + FP1 | 3 | FP1 + [Panel 1] ----- [Panel 2] + FP2 | RP1 RP1 | FP2 + Iface1 | | Iface1 + [Device 3] -----------+ +----------- [Device 4] + 4 5 """ # Create cables cable1 = Cable( @@ -563,139 +572,43 @@ class CablePathTestCase(TestCase): ) cable1.save() cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') - ) - cable2.save() - cable3 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable3.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable 2 - cable2.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connection_via_multiple_patches(self): - """ - 1 2 3 4 5 - [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2] - Iface1 FP1 RP1 RP1 FP1 FP1 RP1 RP1 FP1 Iface1 - - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.save() - cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') - ) - cable2.save() - cable3 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), - termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') - ) - cable3.save() - cable4 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') - ) - cable4.save() - cable5 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') - ) - cable5.save() - - # Retrieve endpoints - endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') - endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') - - # Validate connections - self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) - self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) - self.assertTrue(endpoint_a.connection_status) - self.assertTrue(endpoint_b.connection_status) - - # Delete cable 3 - cable3.delete() - - # Refresh endpoints - endpoint_a.refresh_from_db() - endpoint_b.refresh_from_db() - - # Check that connections have been nullified - self.assertIsNone(endpoint_a.connected_endpoint) - self.assertIsNone(endpoint_b.connected_endpoint) - self.assertIsNone(endpoint_a.connection_status) - self.assertIsNone(endpoint_b.connection_status) - - def test_connection_via_stacked_rear_ports(self): - """ - 1 2 3 4 5 - [Device 1] ----- [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] ----- [Device 2] - Iface1 FP1 RP1 FP1 RP1 RP1 FP1 RP1 FP1 Iface1 - - """ - # Create cables - cable1 = Cable( - termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), - termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') - ) - cable1.save() - cable2 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_a=Interface.objects.get(device__name='Device 2', name='Interface 1'), termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') ) cable2.save() + cable3 = Cable( - termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') + termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') ) cable3.save() + cable4 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), - termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') + termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') ) cable4.save() cable5 = Cable( - termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), - termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') + termination_a=Interface.objects.get(device__name='Device 4', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2') ) cable5.save() # Retrieve endpoints endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') + endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') # Validate connections self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) self.assertTrue(endpoint_a.connection_status) self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) # Delete cable 3 cable3.delete() @@ -703,12 +616,204 @@ class CablePathTestCase(TestCase): # Refresh endpoints endpoint_a.refresh_from_db() endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() # Check that connections have been nullified self.assertIsNone(endpoint_a.connected_endpoint) self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) self.assertIsNone(endpoint_a.connection_status) self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) + + def test_connections_via_multiple_patches(self): + """ + Test two connections via patched rear ports: + Device 1 <---> Device 2 + Device 3 <---> Device 4 + + 1 2 3 + [Device 1] -----------+ +---------------+ +----------- [Device 2] + Iface1 | | | | Iface1 + FP1 | 4 | FP1 FP1 | 5 | FP1 + [Panel 1] ----- [Panel 2] [Panel 3] ----- [Panel 4] + FP2 | RP1 RP1 | FP2 FP2 | RP1 RP1 | FP2 + Iface1 | | | | Iface1 + [Device 3] -----------+ +---------------+ +----------- [Device 4] + 6 7 8 + """ + # Create cables + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + ) + cable1.save() + cable2 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1'), + termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1') + ) + cable2.save() + cable3 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), + termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') + ) + cable3.save() + + cable4 = Cable( + termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1') + ) + cable4.save() + cable5 = Cable( + termination_a=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') + ) + cable5.save() + + cable6 = Cable( + termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') + ) + cable6.save() + cable7 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 2', name='Front Port 2'), + termination_b=FrontPort.objects.get(device__name='Panel 3', name='Front Port 2') + ) + cable7.save() + cable8 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), + termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') + ) + cable8.save() + + # Retrieve endpoints + endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') + endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') + endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) + + # Delete cables 4 and 5 + cable4.delete() + cable5.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) + + def test_connections_via_nested_rear_ports(self): + """ + Test two connections via nested rear ports: + Device 1 <---> Device 2 + Device 3 <---> Device 4 + + 1 2 + [Device 1] -----------+ +----------- [Device 2] + Iface1 | | Iface1 + FP1 | 3 4 5 | FP1 + [Panel 1] ----- [Panel 2] ----- [Panel 3] ----- [Panel 4] + FP2 | RP1 FP1 RP1 RP1 FP1 RP1 | FP2 + Iface1 | | Iface1 + [Device 3] -----------+ +----------- [Device 4] + 6 7 + """ + # Create cables + cable1 = Cable( + termination_a=Interface.objects.get(device__name='Device 1', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 1') + ) + cable1.save() + cable2 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 1'), + termination_b=Interface.objects.get(device__name='Device 2', name='Interface 1') + ) + cable2.save() + + cable3 = Cable( + termination_a=RearPort.objects.get(device__name='Panel 1', name='Rear Port 1'), + termination_b=FrontPort.objects.get(device__name='Panel 2', name='Front Port 1') + ) + cable3.save() + cable4 = Cable( + termination_a=RearPort.objects.get(device__name='Panel 2', name='Rear Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 3', name='Rear Port 1') + ) + cable4.save() + cable5 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 3', name='Front Port 1'), + termination_b=RearPort.objects.get(device__name='Panel 4', name='Rear Port 1') + ) + cable5.save() + + cable6 = Cable( + termination_a=Interface.objects.get(device__name='Device 3', name='Interface 1'), + termination_b=FrontPort.objects.get(device__name='Panel 1', name='Front Port 2') + ) + cable6.save() + cable7 = Cable( + termination_a=FrontPort.objects.get(device__name='Panel 4', name='Front Port 2'), + termination_b=Interface.objects.get(device__name='Device 4', name='Interface 1') + ) + cable7.save() + + # Retrieve endpoints + endpoint_a = Interface.objects.get(device__name='Device 1', name='Interface 1') + endpoint_b = Interface.objects.get(device__name='Device 2', name='Interface 1') + endpoint_c = Interface.objects.get(device__name='Device 3', name='Interface 1') + endpoint_d = Interface.objects.get(device__name='Device 4', name='Interface 1') + + # Validate connections + self.assertEqual(endpoint_a.connected_endpoint, endpoint_b) + self.assertEqual(endpoint_b.connected_endpoint, endpoint_a) + self.assertEqual(endpoint_c.connected_endpoint, endpoint_d) + self.assertEqual(endpoint_d.connected_endpoint, endpoint_c) + self.assertTrue(endpoint_a.connection_status) + self.assertTrue(endpoint_b.connection_status) + self.assertTrue(endpoint_c.connection_status) + self.assertTrue(endpoint_d.connection_status) + + # Delete cable 4 + cable4.delete() + + # Refresh endpoints + endpoint_a.refresh_from_db() + endpoint_b.refresh_from_db() + endpoint_c.refresh_from_db() + endpoint_d.refresh_from_db() + + # Check that connections have been nullified + self.assertIsNone(endpoint_a.connected_endpoint) + self.assertIsNone(endpoint_b.connected_endpoint) + self.assertIsNone(endpoint_c.connected_endpoint) + self.assertIsNone(endpoint_d.connected_endpoint) + self.assertIsNone(endpoint_a.connection_status) + self.assertIsNone(endpoint_b.connection_status) + self.assertIsNone(endpoint_c.connection_status) + self.assertIsNone(endpoint_d.connection_status) def test_connection_via_circuit(self): """ diff --git a/netbox/dcim/tests/test_natural_ordering.py b/netbox/dcim/tests/test_natural_ordering.py index 2d2b5c4dc..5c42b3ab4 100644 --- a/netbox/dcim/tests/test_natural_ordering.py +++ b/netbox/dcim/tests/test_natural_ordering.py @@ -23,28 +23,34 @@ class NaturalOrderingTestCase(TestCase): INTERFACES = [ '0', + '0.0', '0.1', '0.2', '0.10', '0.100', '0:1', + '0:1.0', '0:1.1', '0:1.2', '0:1.10', '0:2', + '0:2.0', '0:2.1', '0:2.2', '0:2.10', '1', + '1.0', '1.1', '1.2', '1.10', '1.100', '1:1', + '1:1.0', '1:1.1', '1:1.2', '1:1.10', '1:2', + '1:2.0', '1:2.1', '1:2.2', '1:2.10', diff --git a/netbox/dcim/urls.py b/netbox/dcim/urls.py index c62800386..36a272cf8 100644 --- a/netbox/dcim/urls.py +++ b/netbox/dcim/urls.py @@ -278,7 +278,7 @@ urlpatterns = [ path('rear-ports//edit/', views.RearPortEditView.as_view(), name='rearport_edit'), path('rear-ports//delete/', views.RearPortDeleteView.as_view(), name='rearport_delete'), path('rear-ports//trace/', views.CableTraceView.as_view(), name='rearport_trace', kwargs={'model': RearPort}), - # path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), + path('devices/rear-ports/add/', views.DeviceBulkAddRearPortView.as_view(), name='device_bulk_add_rearport'), # Device bays path('device-bays/', views.DeviceBayListView.as_view(), name='devicebay_list'), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 725be6990..9ca4c2edc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -32,6 +32,7 @@ from virtualization.models import VirtualMachine from . import filters, forms, tables from .choices import DeviceFaceChoices from .constants import NONCONNECTABLE_IFACE_TYPES +from .exceptions import CableTraceSplit from .models import ( Cable, ConsolePort, ConsolePortTemplate, ConsoleServerPort, ConsoleServerPortTemplate, Device, DeviceBay, DeviceBayTemplate, DeviceRole, DeviceType, FrontPort, FrontPortTemplate, Interface, InterfaceTemplate, @@ -1929,7 +1930,7 @@ class DeviceBulkAddConsolePortView(PermissionRequiredMixin, BulkComponentCreateV permission_required = 'dcim.add_consoleport' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddComponentForm + form = forms.ConsolePortBulkCreateForm model = ConsolePort model_form = forms.ConsolePortForm filterset = filters.DeviceFilterSet @@ -1941,7 +1942,7 @@ class DeviceBulkAddConsoleServerPortView(PermissionRequiredMixin, BulkComponentC permission_required = 'dcim.add_consoleserverport' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddComponentForm + form = forms.ConsoleServerPortBulkCreateForm model = ConsoleServerPort model_form = forms.ConsoleServerPortForm filterset = filters.DeviceFilterSet @@ -1953,7 +1954,7 @@ class DeviceBulkAddPowerPortView(PermissionRequiredMixin, BulkComponentCreateVie permission_required = 'dcim.add_powerport' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddComponentForm + form = forms.PowerPortBulkCreateForm model = PowerPort model_form = forms.PowerPortForm filterset = filters.DeviceFilterSet @@ -1965,7 +1966,7 @@ class DeviceBulkAddPowerOutletView(PermissionRequiredMixin, BulkComponentCreateV permission_required = 'dcim.add_poweroutlet' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddComponentForm + form = forms.PowerOutletBulkCreateForm model = PowerOutlet model_form = forms.PowerOutletForm filterset = filters.DeviceFilterSet @@ -1977,7 +1978,7 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie permission_required = 'dcim.add_interface' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddInterfaceForm + form = forms.InterfaceBulkCreateForm model = Interface model_form = forms.InterfaceForm filterset = filters.DeviceFilterSet @@ -1985,11 +1986,35 @@ class DeviceBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentCreateVie default_return_url = 'dcim:device_list' +# class DeviceBulkAddFrontPortView(PermissionRequiredMixin, BulkComponentCreateView): +# permission_required = 'dcim.add_frontport' +# parent_model = Device +# parent_field = 'device' +# form = forms.FrontPortBulkCreateForm +# model = FrontPort +# model_form = forms.FrontPortForm +# filterset = filters.DeviceFilterSet +# table = tables.DeviceTable +# default_return_url = 'dcim:device_list' + + +class DeviceBulkAddRearPortView(PermissionRequiredMixin, BulkComponentCreateView): + permission_required = 'dcim.add_rearport' + parent_model = Device + parent_field = 'device' + form = forms.RearPortBulkCreateForm + model = RearPort + model_form = forms.RearPortForm + filterset = filters.DeviceFilterSet + table = tables.DeviceTable + default_return_url = 'dcim:device_list' + + class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateView): permission_required = 'dcim.add_devicebay' parent_model = Device parent_field = 'device' - form = forms.DeviceBulkAddComponentForm + form = forms.DeviceBayBulkCreateForm model = DeviceBay model_form = forms.DeviceBayForm filterset = filters.DeviceFilterSet @@ -2033,12 +2058,15 @@ class CableTraceView(PermissionRequiredMixin, View): def get(self, request, model, pk): obj = get_object_or_404(model, pk=pk) - trace = obj.trace() - total_length = sum([entry[1]._abs_length for entry in trace if entry[1] and entry[1]._abs_length]) + path, split_ends = obj.trace() + total_length = sum( + [entry[1]._abs_length for entry in path if entry[1] and entry[1]._abs_length] + ) return render(request, 'dcim/cable_trace.html', { 'obj': obj, - 'trace': trace, + 'trace': path, + 'split_ends': split_ends, 'total_length': total_length, }) diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 5abe4c585..f5de2f509 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -90,8 +90,7 @@ class VLANGroupSerializer(ValidatedModelSerializer): if data.get('site', None): for field in ['name', 'slug']: validator = UniqueTogetherValidator(queryset=VLANGroup.objects.all(), fields=('site', field)) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) @@ -122,8 +121,7 @@ class VLANSerializer(TaggitSerializer, CustomFieldModelSerializer): if data.get('group', None): for field in ['vid', 'name']: validator = UniqueTogetherValidator(queryset=VLAN.objects.all(), fields=('group', field)) - validator.set_context(self) - validator(data) + validator(data, self) # Enforce model validation super().validate(data) @@ -185,6 +183,10 @@ class AvailablePrefixSerializer(serializers.Serializer): """ Representation of a prefix which does not exist in the database. """ + family = serializers.IntegerField(read_only=True) + prefix = serializers.CharField(read_only=True) + vrf = NestedVRFSerializer(read_only=True) + def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data @@ -248,6 +250,10 @@ class AvailableIPSerializer(serializers.Serializer): """ Representation of an IP address which does not exist in the database. """ + family = serializers.IntegerField(read_only=True) + address = serializers.CharField(read_only=True) + vrf = NestedVRFSerializer(read_only=True) + def to_representation(self, instance): if self.context.get('vrf'): vrf = NestedVRFSerializer(self.context['vrf'], context={'request': self.context['request']}).data diff --git a/netbox/ipam/api/views.py b/netbox/ipam/api/views.py index f24c71b17..bf430f633 100644 --- a/netbox/ipam/api/views.py +++ b/netbox/ipam/api/views.py @@ -2,6 +2,7 @@ from django.conf import settings from django.db.models import Count from django.shortcuts import get_object_or_404 from django_pglocks import advisory_lock +from drf_yasg.utils import swagger_auto_schema from rest_framework import status from rest_framework.decorators import action from rest_framework.exceptions import PermissionDenied @@ -73,6 +74,12 @@ class PrefixViewSet(CustomFieldModelViewSet): serializer_class = serializers.PrefixSerializer filterset_class = filters.PrefixFilterSet + @swagger_auto_schema( + methods=['get', 'post'], + responses={ + 200: serializers.AvailablePrefixSerializer(many=True), + } + ) @action(detail=True, url_path='available-prefixes', methods=['get', 'post']) @advisory_lock(ADVISORY_LOCK_KEYS['available-prefixes']) def available_prefixes(self, request, pk=None): @@ -151,6 +158,12 @@ class PrefixViewSet(CustomFieldModelViewSet): return Response(serializer.data) + @swagger_auto_schema( + methods=['get', 'post'], + responses={ + 200: serializers.AvailableIPSerializer(many=True), + } + ) @action(detail=True, url_path='available-ips', methods=['get', 'post']) @advisory_lock(ADVISORY_LOCK_KEYS['available-ips']) def available_ips(self, request, pk=None): diff --git a/netbox/ipam/tests/test_api.py b/netbox/ipam/tests/test_api.py index b38daa079..c20ef6158 100644 --- a/netbox/ipam/tests/test_api.py +++ b/netbox/ipam/tests/test_api.py @@ -785,6 +785,7 @@ class VLANGroupTest(APITestCase): super().setUp() + self.site1 = Site.objects.create(name='Test Site 1', slug='test-site-1') self.vlangroup1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') self.vlangroup2 = VLANGroup.objects.create(name='Test VLAN Group 2', slug='test-vlan-group-2') self.vlangroup3 = VLANGroup.objects.create(name='Test VLAN Group 3', slug='test-vlan-group-3') @@ -818,6 +819,7 @@ class VLANGroupTest(APITestCase): data = { 'name': 'Test VLAN Group 4', 'slug': 'test-vlan-group-4', + 'site': self.site1.pk, } url = reverse('ipam-api:vlangroup-list') @@ -886,10 +888,10 @@ class VLANTest(APITestCase): super().setUp() + self.group1 = VLANGroup.objects.create(name='Test VLAN Group 1', slug='test-vlan-group-1') self.vlan1 = VLAN.objects.create(vid=1, name='Test VLAN 1') self.vlan2 = VLAN.objects.create(vid=2, name='Test VLAN 2') self.vlan3 = VLAN.objects.create(vid=3, name='Test VLAN 3') - self.prefix1 = Prefix.objects.create(prefix=IPNetwork('192.168.1.0/24')) def test_get_vlan(self): @@ -921,6 +923,7 @@ class VLANTest(APITestCase): data = { 'vid': 4, 'name': 'Test VLAN 4', + 'group': self.group1.pk, } url = reverse('ipam-api:vlan-list') diff --git a/netbox/netbox/configuration.example.py b/netbox/netbox/configuration.example.py index 0c9182ab1..2b9788808 100644 --- a/netbox/netbox/configuration.example.py +++ b/netbox/netbox/configuration.example.py @@ -178,8 +178,14 @@ PAGINATE_COUNT = 50 # Enable installed plugins. Add the name of each plugin to the list. PLUGINS = [] -# Configure enabled plugins. This should be a dictionary of dictionaries, mapping each plugin by name to its configuration parameters. -PLUGINS_CONFIG = {} +# Plugins configuration settings. These settings are used by various plugins that the user may have installed. +# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. +# PLUGINS_CONFIG = { +# 'my_plugin': { +# 'foo': 'bar', +# 'buzz': 'bazz' +# } +# } # When determining the primary IP address for a device, IPv6 is preferred over IPv4 by default. Set this to True to # prefer IPv4 instead. @@ -209,18 +215,6 @@ RELEASE_CHECK_URL = None # this setting is derived from the installed location. # SCRIPTS_ROOT = '/opt/netbox/netbox/scripts' -# Enable plugin support in netbox. This setting must be enabled for any installed plugins to function. -PLUGINS_ENABLED = False - -# Plugins configuration settings. These settings are used by various plugins that the user may have installed. -# Each key in the dictionary is the name of an installed plugin and its value is a dictionary of settings. -# PLUGINS_CONFIG = { -# 'my_plugin': { -# 'foo': 'bar', -# 'buzz': 'bazz' -# } -# } - # By default, NetBox will store session data in the database. Alternatively, a file path can be specified here to use # local file storage instead. (This can be useful for enabling authentication on a standby instance with read-only # database access.) Note that the user as which NetBox runs must have read and write permissions to this path. diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2921a67dc..c7116b0af 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -16,7 +16,7 @@ from django.core.validators import URLValidator # Environment setup # -VERSION = '2.8.0' +VERSION = '2.8.1' # Hostname HOSTNAME = platform.node() @@ -479,11 +479,14 @@ CACHEOPS = { 'auth.*': {'ops': ('fetch', 'get')}, 'auth.permission': {'ops': 'all'}, 'circuits.*': {'ops': 'all'}, + 'dcim.region': None, # MPTT models are exempt due to raw sql + 'dcim.rackgroup': None, # MPTT models are exempt due to raw sql 'dcim.*': {'ops': 'all'}, 'ipam.*': {'ops': 'all'}, 'extras.*': {'ops': 'all'}, 'secrets.*': {'ops': 'all'}, 'users.*': {'ops': 'all'}, + 'tenancy.tenantgroup': None, # MPTT models are exempt due to raw sql 'tenancy.*': {'ops': 'all'}, 'virtualization.*': {'ops': 'all'}, } @@ -644,18 +647,18 @@ for plugin_name in PLUGINS: plugin = importlib.import_module(plugin_name) except ImportError: raise ImproperlyConfigured( - f"Unable to import plugin {plugin_name}: Module not found. Check that the plugin module has been " - f"installed within the correct Python environment." + "Unable to import plugin {}: Module not found. Check that the plugin module has been installed within the " + "correct Python environment.".format(plugin_name) ) # Determine plugin config and add to INSTALLED_APPS. try: plugin_config = plugin.config - INSTALLED_APPS.append(f"{plugin_config.__module__}.{plugin_config.__name__}") + INSTALLED_APPS.append("{}.{}".format(plugin_config.__module__, plugin_config.__name__)) except AttributeError: raise ImproperlyConfigured( - f"Plugin {plugin_name} does not provide a 'config' variable. This should be defined in the plugin's " - f"__init__.py file and point to the PluginConfig subclass." + "Plugin {} does not provide a 'config' variable. This should be defined in the plugin's __init__.py file " + "and point to the PluginConfig subclass.".format(plugin_name) ) # Validate user-provided configuration settings and assign defaults @@ -670,7 +673,9 @@ for plugin_name in PLUGINS: # Apply cacheops config if type(plugin_config.caching_config) is not dict: - raise ImproperlyConfigured(f"Plugin {plugin_name} caching_config must be a dictionary.") + raise ImproperlyConfigured( + "Plugin {} caching_config must be a dictionary.".format(plugin_name) + ) CACHEOPS.update({ - f"{plugin_name}.{key}": value for key, value in plugin_config.caching_config.items() + "{}.{}".format(plugin_name, key): value for key, value in plugin_config.caching_config.items() }) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 87f286b1f..1e7210e9a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -48,6 +48,50 @@ {% endif %} - {% if not forloop.last %}
{% endif %} +
{% endfor %} +
+ {% if split_ends %} +
+
+
+ Trace Split +
+
+ There are multiple possible paths from this point. Select a port to continue. +
+
+
+ + + + + + + + + + {% for termination in split_ends %} + + + + + + + {% endfor %} +
PortConnectedTypeDescription
{{ termination }} + {% if termination.cable %} + + {% else %} + + {% endif %} + {{ termination.get_type_display }}{{ termination.description|placeholder }}
+
+
+ {% else %} +
+

Trace completed!

+
+ {% endif %} +
{% endblock %} diff --git a/netbox/templates/dcim/device_list.html b/netbox/templates/dcim/device_list.html index b12e4b5a8..ebee21d18 100644 --- a/netbox/templates/dcim/device_list.html +++ b/netbox/templates/dcim/device_list.html @@ -12,6 +12,7 @@ {% if perms.dcim.add_powerport %}
  • Power Ports
  • {% endif %} {% if perms.dcim.add_poweroutlet %}
  • Power Outlets
  • {% endif %} {% if perms.dcim.add_interface %}
  • Interfaces
  • {% endif %} + {% if perms.dcim.add_rearport %}
  • Rear Ports
  • {% endif %} {% if perms.dcim.add_devicebay %}
  • Device Bays
  • {% endif %} diff --git a/netbox/templates/dcim/devicetype.html b/netbox/templates/dcim/devicetype.html index 568f0433c..2479d58d2 100644 --- a/netbox/templates/dcim/devicetype.html +++ b/netbox/templates/dcim/devicetype.html @@ -102,13 +102,7 @@ Parent/Child - {% if devicetype.subdevice_role == True %} - - {% elif devicetype.subdevice_role == False %} - - {% else %} - - {% endif %} + {{ devicetype.get_subdevice_role_display|placeholder }} diff --git a/netbox/utilities/auth_backends.py b/netbox/utilities/auth_backends.py index 6e968a241..6342bad2b 100644 --- a/netbox/utilities/auth_backends.py +++ b/netbox/utilities/auth_backends.py @@ -48,7 +48,7 @@ class RemoteUserBackend(ViewExemptModelBackend, RemoteUserBackend_): try: group_list.append(Group.objects.get(name=name)) except Group.DoesNotExist: - logging.error("Could not assign group {name} to remotely-authenticated user {user}: Group not found") + logging.error(f"Could not assign group {name} to remotely-authenticated user {user}: Group not found") if group_list: user.groups.add(*group_list) logger.debug(f"Assigned groups to remotely-authenticated user {user}: {group_list}") diff --git a/netbox/utilities/custom_inspectors.py b/netbox/utilities/custom_inspectors.py index 25764b0be..2cbe1cfc5 100644 --- a/netbox/utilities/custom_inspectors.py +++ b/netbox/utilities/custom_inspectors.py @@ -92,7 +92,7 @@ class CustomChoiceFieldInspector(FieldInspector): value_schema = openapi.Schema(type=schema_type, enum=choice_value) value_schema['x-nullable'] = True - if isinstance(choice_value[0], int): + if all(type(x) == int for x in [c for c in choice_value if c is not None]): # Change value_schema for IPAddressFamilyChoices, RackWidthChoices value_schema = openapi.Schema(type=openapi.TYPE_INTEGER, enum=choice_value) diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fd528f827..d95c86527 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -10,6 +10,7 @@ from django.conf import settings from django.contrib.postgres.forms.jsonb import JSONField as _JSONField, InvalidJSONInput from django.db.models import Count from django.forms import BoundField +from django.forms.models import fields_for_model from django.urls import reverse from .choices import unpack_grouped_choices @@ -123,6 +124,19 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) +def form_from_model(model, fields): + """ + Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used + for creating objects, but want to avoid the model's validation (e.g. for bulk create/edit functions). All fields + are marked as not required. + """ + form_fields = fields_for_model(model, fields=fields) + for field in form_fields.values(): + field.required = False + + return type('FormFromModel', (forms.Form,), form_fields) + + # # Widgets # diff --git a/netbox/utilities/ordering.py b/netbox/utilities/ordering.py index 346a99488..c5287b1e1 100644 --- a/netbox/utilities/ordering.py +++ b/netbox/utilities/ordering.py @@ -75,7 +75,7 @@ def naturalize_interface(value, max_length): if part is not None: output += part.rjust(6, '0') else: - output += '000000' + output += '......' # Finally, naturalize any remaining text and append it if match.group('remainder') is not None and len(output) < max_length: diff --git a/netbox/utilities/query_functions.py b/netbox/utilities/query_functions.py new file mode 100644 index 000000000..ee4310ea7 --- /dev/null +++ b/netbox/utilities/query_functions.py @@ -0,0 +1,9 @@ +from django.db.models import F, Func + + +class CollateAsChar(Func): + """ + Disregard localization by collating a field as a plain character string. Helpful for ensuring predictable ordering. + """ + function = 'C' + template = '(%(expressions)s) COLLATE "%(function)s"' diff --git a/netbox/utilities/tests/test_ordering.py b/netbox/utilities/tests/test_ordering.py index d535443ea..8e85f9e8c 100644 --- a/netbox/utilities/tests/test_ordering.py +++ b/netbox/utilities/tests/test_ordering.py @@ -30,29 +30,32 @@ class NaturalizationTestCase(TestCase): # Original, naturalized data = ( + # IOS/JunOS-style - ('Gi', '9999999999999999Gi000000000000000000'), - ('Gi1', '9999999999999999Gi000001000000000000'), - ('Gi1.0', '9999999999999999Gi000001000000000000'), - ('Gi1.1', '9999999999999999Gi000001000000000001'), - ('Gi1:0', '9999999999999999Gi000001000000000000'), + ('Gi', '9999999999999999Gi..................'), + ('Gi1', '9999999999999999Gi000001............'), + ('Gi1.0', '9999999999999999Gi000001......000000'), + ('Gi1.1', '9999999999999999Gi000001......000001'), + ('Gi1:0', '9999999999999999Gi000001000000......'), ('Gi1:0.0', '9999999999999999Gi000001000000000000'), ('Gi1:0.1', '9999999999999999Gi000001000000000001'), - ('Gi1:1', '9999999999999999Gi000001000001000000'), + ('Gi1:1', '9999999999999999Gi000001000001......'), ('Gi1:1.0', '9999999999999999Gi000001000001000000'), ('Gi1:1.1', '9999999999999999Gi000001000001000001'), - ('Gi1/2', '0001999999999999Gi000002000000000000'), - ('Gi1/2/3', '0001000299999999Gi000003000000000000'), - ('Gi1/2/3/4', '0001000200039999Gi000004000000000000'), - ('Gi1/2/3/4/5', '0001000200030004Gi000005000000000000'), - ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006000000'), + ('Gi1/2', '0001999999999999Gi000002............'), + ('Gi1/2/3', '0001000299999999Gi000003............'), + ('Gi1/2/3/4', '0001000200039999Gi000004............'), + ('Gi1/2/3/4/5', '0001000200030004Gi000005............'), + ('Gi1/2/3/4/5:6', '0001000200030004Gi000005000006......'), ('Gi1/2/3/4/5:6.7', '0001000200030004Gi000005000006000007'), + # Generic - ('Interface 1', '9999999999999999Interface 000001000000000000'), - ('Interface 1 (other)', '9999999999999999Interface 000001000000000000 (other)'), - ('Interface 99', '9999999999999999Interface 000099000000000000'), - ('PCIe1-p1', '9999999999999999PCIe000001000000000000-p00000001'), - ('PCIe1-p99', '9999999999999999PCIe000001000000000000-p00000099'), + ('Interface 1', '9999999999999999Interface 000001............'), + ('Interface 1 (other)', '9999999999999999Interface 000001............ (other)'), + ('Interface 99', '9999999999999999Interface 000099............'), + ('PCIe1-p1', '9999999999999999PCIe000001............-p00000001'), + ('PCIe1-p99', '9999999999999999PCIe000001............-p00000099'), + ) for origin, naturalized in data: diff --git a/netbox/utilities/views.py b/netbox/utilities/views.py index 0d5153740..b671eec9c 100644 --- a/netbox/utilities/views.py +++ b/netbox/utilities/views.py @@ -972,25 +972,32 @@ class BulkComponentCreateView(GetReturnURLMixin, View): new_components = [] data = deepcopy(form.cleaned_data) - for obj in data['pk']: + try: + with transaction.atomic(): - names = data['name_pattern'] - for name in names: - component_data = { - self.parent_field: obj.pk, - 'name': name, - } - component_data.update(data) - component_form = self.model_form(component_data) - if component_form.is_valid(): - new_components.append(component_form.save(commit=False)) - else: - for field, errors in component_form.errors.as_data().items(): - for e in errors: - form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + for obj in data['pk']: + + names = data['name_pattern'] + for name in names: + component_data = { + self.parent_field: obj.pk, + 'name': name, + } + component_data.update(data) + component_form = self.model_form(component_data) + if component_form.is_valid(): + instance = component_form.save() + logger.debug(f"Created {instance} on {instance.parent}") + new_components.append(instance) + else: + for field, errors in component_form.errors.as_data().items(): + for e in errors: + form.add_error(field, '{} {}: {}'.format(obj, name, ', '.join(e))) + + except IntegrityError: + pass if not form.errors: - self.model.objects.bulk_create(new_components) msg = "Added {} {} to {} {}.".format( len(new_components), model_name, diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index f4c2a36ec..9ba5ff032 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -15,7 +15,8 @@ from tenancy.models import Tenant from utilities.forms import ( add_blank_choice, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, CommentField, ConfirmationForm, CSVChoiceField, DynamicModelChoiceField, DynamicModelMultipleChoiceField, - ExpandableNameField, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, TagFilterField, + ExpandableNameField, form_from_model, JSONField, SlugField, SmallTextarea, StaticSelect2, StaticSelect2Multiple, + TagFilterField, ) from .choices import * from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -827,24 +828,18 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): label='Name' ) + def clean_tags(self): + # Because we're feeding TagField data (on the bulk edit form) to another TagField (on the model form), we + # must first convert the list of tags to a string. + return ','.join(self.cleaned_data.get('tags')) -class VirtualMachineBulkAddInterfaceForm(VirtualMachineBulkAddComponentForm): + +class InterfaceBulkCreateForm( + form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), + VirtualMachineBulkAddComponentForm +): type = forms.ChoiceField( choices=VMInterfaceTypeChoices, initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, widget=forms.HiddenInput() ) - enabled = forms.BooleanField( - required=False, - initial=True - ) - mtu = forms.IntegerField( - required=False, - min_value=INTERFACE_MTU_MIN, - max_value=INTERFACE_MTU_MAX, - label='MTU' - ) - description = forms.CharField( - max_length=100, - required=False - ) diff --git a/netbox/virtualization/views.py b/netbox/virtualization/views.py index 291392eb4..ff115d211 100644 --- a/netbox/virtualization/views.py +++ b/netbox/virtualization/views.py @@ -366,7 +366,7 @@ class VirtualMachineBulkAddInterfaceView(PermissionRequiredMixin, BulkComponentC permission_required = 'dcim.add_interface' parent_model = VirtualMachine parent_field = 'virtual_machine' - form = forms.VirtualMachineBulkAddInterfaceForm + form = forms.InterfaceBulkCreateForm model = Interface model_form = forms.InterfaceForm filterset = filters.VirtualMachineFilterSet