From f7e7699d93c1d8752857b781a868f7df55f3fb8c Mon Sep 17 00:00:00 2001 From: toerb Date: Wed, 8 Apr 2020 09:30:14 +0200 Subject: [PATCH 01/35] add rack width of 21 inches for ETSI racks --- netbox/dcim/choices.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 79f00bce4..98bb41aa4 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'), ) From 8b57a888e797d7077a29bfcb6bb665d8eead9495 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 11:31:16 -0400 Subject: [PATCH 02/35] Post-release version bump --- netbox/netbox/settings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 2921a67dc..7d2bdd2ad 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-dev' # Hostname HOSTNAME = platform.node() From e97205922c5f70c4d40e7e0e493af69b456cb97c Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 13:49:34 -0400 Subject: [PATCH 03/35] Fixes #4481: Remove extraneous material from example configuration file --- netbox/netbox/configuration.example.py | 22 ++++++++-------------- 1 file changed, 8 insertions(+), 14 deletions(-) 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. From d37a74846a6a424faf66cd7ac4d2b949a539a698 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 14:07:44 -0400 Subject: [PATCH 04/35] Remove format strings to ensure compilation under old Python releases --- netbox/netbox/settings.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7d2bdd2ad..ea3852711 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -644,18 +644,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 +670,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() }) From 9ed0494b4502d107f5d67f9bef05b90319a0a4f8 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 14:22:17 -0400 Subject: [PATCH 05/35] Clarify requirement for Python 3.6 or later --- docs/installation/3-netbox.md | 6 ++++-- docs/installation/upgrading.md | 3 +++ 2 files changed, 7 insertions(+), 2 deletions(-) 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. From 0ffc74c66965f70e2cf535de9d2381beef946443 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 14:37:11 -0400 Subject: [PATCH 06/35] Fix link to logging configuration docs --- docs/release-notes/version-2.8.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index c1392e99d..753ce68f2 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -35,7 +35,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 From 819f842cf1a8c5e2f2816090a6de53611babce0f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 13 Apr 2020 14:38:26 -0400 Subject: [PATCH 07/35] Call out requirement for Python 3.6 or later --- docs/release-notes/version-2.8.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 753ce68f2..df8bf7f71 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -2,6 +2,8 @@ ## 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. From fc1feec8bfc3fe7f836964687ef6cdb14bf5bbbf Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Apr 2020 09:43:12 -0400 Subject: [PATCH 08/35] Fix format string --- netbox/utilities/auth_backends.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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}") From 2dbc04c6fb9fedfe3f9e4ea0438e20475bed9248 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Apr 2020 10:03:02 -0400 Subject: [PATCH 09/35] Fix typo --- docs/configuration/optional-settings.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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.) --- From 788909de94e46ec2617b7278253f9ecc687c9bf2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 14 Apr 2020 12:13:05 -0400 Subject: [PATCH 10/35] Fixes #4489: Fix display of parent/child role on device type view --- docs/release-notes/version-2.8.md | 8 ++++++++ netbox/templates/dcim/devicetype.html | 8 +------- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index df8bf7f71..0b0539c02 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,5 +1,13 @@ # NetBox v2.8 +## v2.8.1 (FUTURE) + +### Bug Fixes + +* [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view + +--- + ## v2.8.0 (2020-04-13) **NOTE:** Beginning with release 2.8.0, NetBox requires Python 3.6 or later. 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 }} From 1ce0191a744231bf7b04a65a4625a4f2ed7330b2 Mon Sep 17 00:00:00 2001 From: kobayashi Date: Wed, 15 Apr 2020 01:02:11 -0400 Subject: [PATCH 11/35] Fixes #4361: Set correct type of connection_state --- docs/release-notes/version-2.8.md | 1 + netbox/utilities/custom_inspectors.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 0b0539c02..76c46e944 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -4,6 +4,7 @@ ### Bug Fixes +* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in swagger schema. * [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view --- 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) From e0f819691fb4a11a07205d46697d99a95d598151 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Apr 2020 09:37:30 -0400 Subject: [PATCH 12/35] Fixes #4496: Fix exception when validating certain models via REST API --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/api/serializers.py | 6 ++---- netbox/dcim/tests/test_api.py | 5 +++++ netbox/ipam/api/serializers.py | 6 ++---- netbox/ipam/tests/test_api.py | 5 ++++- 5 files changed, 14 insertions(+), 9 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 0b0539c02..da131857a 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -5,6 +5,7 @@ ### Bug Fixes * [#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 --- 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/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/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 5abe4c585..4e596631d 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) 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') From cb84e3bb2e665b081320457fcd593c3e2de55e83 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Apr 2020 09:41:57 -0400 Subject: [PATCH 13/35] Closes #4491: Update docs to indicate support for nesting objects --- docs/models/dcim/rackgroup.md | 2 +- docs/models/tenancy/tenantgroup.md | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) 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. From 5205c4963f2c8ebae2087d1b764c14d9c5f631d3 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Apr 2020 15:46:41 -0400 Subject: [PATCH 14/35] Refactor cable tracing logic --- netbox/dcim/exceptions.py | 9 +++++ netbox/dcim/models/__init__.py | 23 ----------- netbox/dcim/models/device_components.py | 25 ++++++++++-- netbox/dcim/signals.py | 51 +++++++++++++++---------- 4 files changed, 61 insertions(+), 47 deletions(-) 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/models/__init__.py b/netbox/dcim/models/__init__.py index 144bcc28a..2c1940296 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2205,26 +2205,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..58af8bc91 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -10,6 +10,7 @@ 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 @@ -117,10 +118,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() @@ -186,6 +184,25 @@ 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 + try: + endpoint = self.trace()[-1][2] + if endpoint is not None: + endpoints.append(endpoint) + + # We've hit a RearPort mapped to multiple FrontPorts. Recurse to trace each of them individually. + except CableTraceSplit as e: + for frontport in e.termination.frontports.all(): + endpoints.extend(frontport.get_path_endpoints()) + + return endpoints + # # Console ports diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 4ea09655f..2b922ebb5 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 = 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() From 29707cd4968004eb45238c2bba11f11a3680c815 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 15 Apr 2020 17:09:04 -0400 Subject: [PATCH 15/35] Adapt tracing view to account for split ends (WIP) --- netbox/dcim/api/views.py | 2 +- netbox/dcim/models/device_components.py | 36 +++++++++++++++---------- netbox/dcim/signals.py | 2 +- netbox/dcim/views.py | 10 ++++--- netbox/templates/dcim/cable_trace.html | 15 +++++++++++ 5 files changed, 46 insertions(+), 19 deletions(-) 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/models/device_components.py b/netbox/dcim/models/device_components.py index 58af8bc91..f3cf0e3c8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -92,7 +92,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), @@ -157,12 +163,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() @@ -172,9 +178,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: @@ -191,15 +201,13 @@ class CableTermination(models.Model): endpoints = [] # Get the far end of the last path segment - try: - endpoint = self.trace()[-1][2] - if endpoint is not None: - endpoints.append(endpoint) - - # We've hit a RearPort mapped to multiple FrontPorts. Recurse to trace each of them individually. - except CableTraceSplit as e: - for frontport in e.termination.frontports.all(): - endpoints.extend(frontport.get_path_endpoints()) + 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 diff --git a/netbox/dcim/signals.py b/netbox/dcim/signals.py index 2b922ebb5..c94ecf61e 100644 --- a/netbox/dcim/signals.py +++ b/netbox/dcim/signals.py @@ -52,7 +52,7 @@ def update_connected_endpoints(instance, **kwargs): # Update any endpoints for this Cable. endpoints = instance.termination_a.get_path_endpoints() + instance.termination_b.get_path_endpoints() for endpoint in endpoints: - path = endpoint.trace() + path, split_ends = endpoint.trace() # Determine overall path status (connected or planned) path_status = True for segment in path: diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index 725be6990..c10a821dc 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, @@ -2033,12 +2034,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/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index 87f286b1f..fc637f9ef 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -50,4 +50,19 @@ {% if not forloop.last %}
{% endif %} {% endfor %} +
+
+ {% if split_ends %} +

Trace Split

+

Select a termination to continue:

+ + {% else %} +

Trace completed!

+ {% endif %} +
+
{% endblock %} From 8ea611df446bfecf0886eaa6b39552b8348852b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Apr 2020 11:04:15 -0400 Subject: [PATCH 16/35] Changelog for #4464 --- docs/release-notes/version-2.8.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 297f3ee43..3940206ac 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -2,6 +2,10 @@ ## v2.8.1 (FUTURE) +### Enhancements + +* [#4464](https://github.com/netbox-community/netbox/issues/4464) - Add 21-inch rack width (ETSI) + ### Bug Fixes * [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in swagger schema. From f80eb1606012a15d04d760f3ca0a2be95c4b5b6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 20 Apr 2020 11:06:52 -0400 Subject: [PATCH 17/35] Closes #4505: Fix typo in application stack diagram --- .../installation/netbox_application_stack.png | Bin 25833 -> 25852 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/docs/media/installation/netbox_application_stack.png b/docs/media/installation/netbox_application_stack.png index 56de2070aca1f3ea9c17316b0ff97e190454915a..e8634490074d8e38883ca333896c2cdf513e5631 100644 GIT binary patch literal 25852 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 EAB}B!RR910 literal 25833 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νsinlw@=3!;IE#sY)^}*tZXfeh3)#7 zlRo1Gs48O~<9&RqZRK>BOGIj?nCd2Q_c!US$Rf+I{iKcD=eKEd-FqdUNtdjdpRk?0 zKMOtR28^|nN)%m!Lyeaism$`;1rjw@sI0# zP?ATJAt>n$0>Hv3UiY${T26iK8aubrofLca!=ttAlprO*Np!ToEbDiO^m}#T1Hfp% zSsr$NfJx(NB>Ah`GTbRN$Ohj=QiiRDE_!3no|So@Q$u7| zwf=abnwM$HM`{(#LJK zX)qT8DW5f&GGcWZvvvKEH6p4m|A1lkthWfY%F$~2!xwNbY?6!&Lv!;3N3SW&$T7w+ zhxnoF$j>t$JyX33zS{8s?2n)`pMeK9UfR@Buo412|Gj^r(#-@iy_*?O%@s^iQBd9a z(JdX4_A-tjSkj* z`6Byl0K4a`wg1|Gm2PY5syY+P!!)DkarvjE(j&i<)-{ECm@2mScVxAgxWPw6pP>wl zLzZU?lejsBXBsZ-QI%z@>O{69CmDxlw#=#gNw zw3}-bkY0qX&xNPN?#oo zwK!;bUDRU)0fTiD?gvvBh3@o+(O#C#j6#4=RnpaJpcFXj-&3D3hmcv_`YYD49lB4b z*@EL{(^q}vKtUVDMN2x$Y=<1=!UtYy)ENXbB(nNfH5gBOf(kxp(_r}XuW2JEuhHPRoA;L;dQ1vr6H zy{aG?off?R_KRjoXpcSpXWeAOBqbdq>3>vYguNNndEcZM3F5~pgA6*)?-*!V5SfwIMhyQpQAH_Zg>;F$5CO&k0#>FQo z0a2TC1e7(~93oL>(6Z=#G{4Ec)@3ZYXtS9z(N5QR|7N>u2WHicbt%kSh0F4lc}vBm z%xPbRKt>uphq{S1|2VwQ0<=|E&fBEcXu1gxc2Cca;#9^`p=?_p0w}N6&o9j3Zvs@O zmAsr(=s(%>Kt^5jQe2)}^PC%|_3NYg@%9klG{l7*b&!MH4Lkl3zFWuS^;dg}@&}PY z2)!FEZF%|Io~&3IV5OXE$uVl!hBOH~9L=uc_`{Vr>NdT2xZgoilX_^xhA9*4i!%e) z-G#ow7F3J@I=^b=a$a+3N>1~O=Hxi`;64alRO+h7gv^+G_FJzZQJZ^yCDR(i zuk!0KRI;h}CNIq|ALERJ5hq@_T))L;>GWuE`<0qPF_4M zE@Y(GGj{1pD&o{2YP-85JPGiU%+=EC<)OyRdWgu6DSjaO=LA>6pn8ULsSX^0Z)d70 zDWtGzV^^+NlYKhcmANPLaryZvF@3Ke=vJ$WaA=4@vIHNy?BfK3{+Rn)i8bGaw{+iw z3%@G38%V}3Ysy3~$1&TdDE`ecD{OGz%ckVMx=RiJL!9Ig$KLjU1IttY^<}RQ;TO@^ zBf^_v0=ukm{XnnVCoK`z+X(0qnPM&f@2%z$E(^6A`w#0T&Q_W3r781#Rl^^p=`On$M z4wCG-8!yw+MUvb`s9MNAz9AOTqUWM1-~Y*vi1yjnjQ;Bo#8{*^k3tuOD+(d$9`wMFY^~}HoZTH#o1utWBQBCs4Rk1HV9Z*?q#Q-9nrhMFIMAXmWDwl zRJ4TO2!-i5pgvUv^dS!Gvl5EiqXT=l*1(67`vL>a=t}*HAV} z{XZa^AcKIji334;FdWJ3XD%X9Iv1}j64=7tV)?;!v1`$8ZPb~FVhzRoo_d_ME4{It81av;R)wgkglNAL8I zZ#(Au&p{flLSz7Mom+3xTQJfd5f*C9bn{Liq(h7QvHFUNoGKd?mf$APJou4kE0v50 zHUvUmPpJDG!NwRe4j1yvg}XEard{yo%8UKBK#ui zbyJN4PKprsU5SI*19LufEeg!^RKdeZ8q8ZPuat}y6`&pqvFoOQmT|8tb3}$(Q4NKr zV*{yWWjUxk>9E)DPHKpEY4m^yaX%bosWVyv?{rdVev8To>3yRiOJM=&)VUO>ndK^c zW8y`V!+lv8647Zs%Y^-#c>Ajkaec^EHa0^NbhlYsL`%9*%Q)f#L{C1^Q-;+>mPh@S_+6-BN#q}~1!h`{0xDB=Ur65)%}>0uxKTyH}byzVD&!ye00o9NhA zBYkzUY;z}bez*z_Mu3FRH>wAL^kYuH^rZ~|GTijVeuckQSv^`{xJXQNR1SEaE8M_3 zJT%;K_i`=aOVIo_s)QNy`t>bp-*5H;fU)8J0?#a_zB*pe?M}u?s`t(Uwc`;!Swg&B z?{XGX;IdJgfmg$TBzJ>zQI6ORhe0a!&Y#ybGj__$@*?@qN|b--+~c zJN4={pUxzFs0^5$IILg^;17CfmkSjBnP?fN=zp+Uz%K4qSMXbAd$piBprn3~O2gO+ z6^!Xdrq^_wtn*b3n6L}ftP-sou_L1-&mPXmd_QVf5;_Q767n2Ae)h?1F!l++xc_^O z^Z$&?|1T1yqv~TCUiI%4)x8#sKifSU`UlO0g|=CeuM)SS{)ee|?Fnd4y8nl%VdMa)3rv2$LDoMiL-8<96`1QbXqAN^VDbGGw&ay`|1rkl#|3b1Uk+twni zF}pXAzx262iN6wh#s?tsvp7SGK{raX*T1+$*ftZW*Z>Bvsq-Bl86j@aJDrbCid=cQ zdKO3+eH&2w-espxHzVI%~?7J9t%;t&}`#s?D^SYzDt0ndtrdYaX_kj^b z4Kmgoh;-E)=3E5lw%JF+BTe1D{3}atIB$D__-%&(4b(- zR>wW>b>uQ%3u*Uuh74`3~&T8FR*7}!pUDMXorQb|XvWqpe{DZ+P zRX#JYJP&RHvE6+D=f_Mc+b|>%YrZ?{?A&0>uOft{- zQ0B8uI;#zWwkPHtE2&(~^Ij@#1wZWS1e)pE-b@q(v{nk7)~?TU1~10zM79=D{b=&v zmyzIS-oTxXknx<|lo|Ria|pNV{(O?Xxc6VW(=9Lk%DqrO(De!m$v3j6OrX#cPk}uS zQf3m>sBTuAlPXIadfU&ZZqzBTYo;ioYoA&0sMRQ%Y}n9iASSET&B$it&h`mp|5q5V zE=n(11bM>d{nu3HBAyGR2Y;tuQ3~}7O!wAo+>{RtnUXv?$~bhAhYbs`X3}>*e!uYB ziDTPd{_8Xo*38SD57D@6Lb9SO5xYP&R;3P{r3R_X!-w6Mi9`kpUsl5ZBTY`t_sdVeWeMt(Um%(>&&VNJP3o*)x5E_s$&|LY{|lX1UV(+oH%bR8 z0Gd+&2FFN9_vbMoAnHE$M+CS;;bU1_g{=g;u0+6I`o&TtKmQp_{+DEie_ol zpn_l1;WHv=wB?%Dd@ELk@XZ@R>qI}qF+;d$QQ2~#VkDb89DpqR=0C_63`W)llB;^q zQ6oTLP8-$q+$&J>fynJ^g0Pc?$H44DM;KFn|M0Dt(Fuk}Q~ea^EL77ssb^eQjBX|6 z{12!f8qNpt7t^{XhS3sm1YVi26x6LNk3s~NAV%1>*{M5VFHY>RNGFz|NNQ_fLE_bV zS)uL6E+UTHw@e6uKsE9r0XBVNsOq>9SlCOZUI~3FF*{-68bI9?3Z!Ibl)kBV8IE`U z@acr!0Nq43EA3DoD04+zN;7DT-534n^BfYuqy?PWJWs5=@Bdq`yQ&?J2=zuw zePG7v3y(GV&^ATWr7%9$kMKbk8?NWRhz2y*Kd+H3 z_?1zQOz3WDD!qz^5&7^=o8?CrlO#a_ge=lQL?45R6!@gN4)s%|2FSaGuENZpM3jUwBn0K$ zxA6MpU20I;Y3TDvtJuHng6<((e|tmIArqaq-|~Nw(DG!?1UX zNf%YRK2|}pRJ*G_{BvZ#dH7~>T0YvfNINpl)Z>M{g5xQF5O^tQISM!^8n-r2iM*fcZO`DUR9ya^8*^JdJ2wXvQuZCE6Sb>8%TqsbEh>V5cGH=l% zIks@uc44&!wFVVAX55?365MohG`A_y_2M@?;r^o^6h=e?ZX5OuyEuR!1zwu^Sq*tf z26gEY(tqf=kX9oZ9cHVo54@MKMje>!)HBZKe#xbK-klouqp2#&5ir-ULrbks9JvtMyI$a zFxxi z{7zO7PP*UVj*#08ZG@!yv()EA-V2lN3 zHQOLuWg#X)?L{gZqNMJksoPGMzWM6xKq7qMedwj8LqbNFR(jT!Ca4NjUAD}78H7-j zr3t_HCRX8XV9o*=xl6>tAWs`N?21b9+O%|^l9A{d_gZo89i^Og7k(Lz$g)d;@~qht z;TBkB8Fr*VUt#CHsG=&awEOkfLZ;V4(wcg28*?Bl!OFV4A{gdC-n1OkGG6f8rmn{q zo^nUEi~DY3O>D?ucMkp6FP4{Jy$-p;hTaNkkmtd_Q?}?iWO_a*3~5ups^V{MOnjBg z5tmI41AWa(%c))~E$Mo@ti>Sj5YnsZNMHx-G=Ve_UKF|oIQ#=*MMxQoAf#9nT_db? zM=vKR)WWn14t$e>z|uU0O%GZT_W!0y;xcWIXx|mWtMv-SN!jI^jp!!d%$66SAJGpq zX(ygj?{ash(*h5Vl{$Gx3aoD}qNSn;k21TY1S|ks3R(h3mN-sP#aBSdOWo0K@R&yy zs$JK#9=6KWYRg!ci1*|jsP?Us_hg403zI;jnISXA7CmzR4*sMN!oZdviLbYWggjw1 z04M(3Yq78?z@M9pI^Di(KX75{P&31jA=#zCcMf|1MH%!!5yOrA>`E80R z@EfS4L0%hTkNbq?-d?!&MamBdC5MPh)bTv0l>qfDj?bvMxdY>y5j} z#Qoim>iZcoBbKf|@vNJ4NH$dPHohR59?$jVZs~oYqm0Dpq*J`S*1NZe-?V_69 z&ZNgrZ_6{F5=8~H=sP=b*1cY}7Mh8Pd=jnN>ukNYKOE~-`klnRi&l)IOFjJ}?r4dt z9q`wgNqQvbu^l&!y77|Ph!AvUpG9>XLcczU= z=kfP!F3tFN9ui3+&`}|3-4Sq+LEBk=sgIs{H+D94QM5T+5;C?n>vn2&&JNme4!xzy zHZk(2`puUxy^j z57R4*9!D*u6_(v)sjY4sN%H-jBOJVpfA?1yNq7 z3(AK{nyr`*+WtK;TO#Sv=&PB>2%b5|$J_x7%7;+>R?K_tQ~v0!u)=6GY38vBWRAgv zJAg*{5T*HlsL({=nd+hWOq5RD0>kaGBO_=lrcoD7T9Z5Q4H;D-;Cp7zqZ>Csr&Bk+ zE7^V*=vvlhuHM%r1*VaAnD_P{madPMB?ug~>P2md2*#lEk(KyzgLVY8BYHXf=_7fm z##oipcLN#7MaStGPPWq_1L$|=rF2eznhLt_DvRG?e>F3;W8BjHq~#MkpX-{A1gzKFBH1=PJ`#8T#Rx6jfIKTGlK^}{yq@M*ndyT z88&$?s(Y9ee6MK;x%5!?S zcw~9VG)c;3>(9u7+mPQ3f=x;~$DbiG@0V6rCrHjCbr+5rcT?}GWJCHcYM9o!M6R4z zcKzW*`^)VO;ATozGB%AWtI@t5b`KW^Y)d^-CB=M>epATg5^4{)q#Aw5NKGO5xHtSV z(tqINqM6)Baxcfm5Nf#0PPg2Mia>32?e5^Sk;YJ9I4g%&V;en|%nF={e*FS5P-Yn*bIGD~LVf#@$DAch&?yTaz-4$@U_Mx<6 zZ@gMakji4^MvSRPiqOyrm09)lx6rN>$Cm|`i82Apu`9XENB;gp)!Q}Vd;JOfNyGM) z6W*)kL#8jk-`hEAppvlz?)=WB8vmKqYc{HAy&9XkPsw;MZ55G=eR?;j=%CX>+7maQ zWFPI8y~zj&>fJga>T@BzoA6)H%RVu_Zozbdr%kpfK<; z+XHx-8067Xo|)fz?YFH1{F5tCXz61y3gv;r%qx&oUQFVz`VMn@<)Pjf70>f99I~Srj6zjB4 zBBUQYuMfO|GcoQj#a^9t9tJIU+8}#d1ebD%ry}~!W{xc##utc~pHCQ`?Br0?Ri8~a z@vr4z8rM+k{%fRM$s3=;i`LqufhPwaeHkfbgwfMwP7GJzt=-{zSc5FZGDi9OJ<9CO zo5+y2)Qe^H7)+*l;#$5rkRsN+u;zk2O|GfjJ5|$VPOw-^%vlm*Hl0W~S;{N&H#{k< zQ(JqMSGS_ik6g%6q!e%YsPEw|bl2XKBtPlFURmo8tmB9cf}&_`#ovAB^~gyazjQtk zLcT#=`fwIy=jz6Ba4-`Y@SfVb;HboGZ`Bcv8x5g;h(z`!@te*I+N={#bqZPftYyUU zSN05~Op*@;O!clyO%OyOxPC{n%lstP7kRf{H4~wa+h3RY?)Fvq6dT}w^0#(>bYV%| zab6JV86kE$uy!bK*?tsiVu_?~msHrNG}~yP3`B&wrz|RLZgU?_ZaYDpnsYBTlkRn@gCkdoqoaN$2QsXl9mHo%AjjNkkeuz%y0QN@uK za);gy|0yVK!GihOgc9&l96*csL@vPOK&%3P_8Ryv2Ka;G`19Yu;Sv--?a2&1y-ZE| zd;oAZeuDl|D(Z0FZs!nd8ENdZ>be@R{#4&${h?sV2Igh+;F3N-lG^B__KuM@jY-Yz z*~)eMN?X6<-&HHU+aKrsXFfc$89RHp)xNSWlFV^%1==?^Wa`7`vr^ogxFfM%^L1Uq zVZ3Su5yt<$_II3#yPc_xEm_rRB-rw3W8~F^9LtX*`93ds*At_5sM*;n9laB5A_| zYM7B-U{?OB5(T5wZokgJ`cD4ga_!z|p8K%}8aLs~j9qNsKb%i;oZr6%aub3ew9I^? zpw3-l%`D{tyS=qOmCTGMo=tR?;t)wubNN;O49fWg+rrTkGoMjw9q2$#pGh|#yB@k`Dy z;*gL_muXMtMbOrwK+e8TZQGelc7hysAW}iin*JhE;CRZ!!#>WIM76yghjDM9*uKw; zZoK~BVCJ-o?f&fmfxQeLUc{5Yt}{v&(Wm+|;REdMOu%~_?Lxq+$Z66n>)T7}uTb-K zv)dc9f0*xQ$vN)#6!nNYD(7k!)0bx(!0cRwc0jcYff-q8;fQQUjWbce>dsEquRaLh8bMUML9ZL8`I%FL@g4U@qMNKCs<4Rse zb`(90Eo~5VGDVi3x^DeBEbsN3med-Y7F*!7BRxp*tS2bnG8MKnm{^kqtM!~ffT;qY zTD4O{hdSBA^wfv)p=bxy-J^5~fv!lBd?4vOD@2D{O1%+aUxzWNG>*q|0KqjNC>s(& zjVq&t4F~k+XjPl}ZZ`G0Nbirkl=~e>R47X8co%H&6xDn|$EAKn&qx}JOu+YheR$cJ zCu}Px+@JX$*ABRU_f*@9JiaRlq{qft7;tZlYiEG>H7`NY#xKK-Xzx^{_%EdExCuZR zv`0JM;~Z<<*tDLYV%$gVN?9CQYPpvKh}po+T+i>cKgjT*l6`?**a8cRJNpg`w91xo z-n`Qhqx|>ERM@K89Rre^*C}kiOXtF?2JIP4@k16Oht8h#zt$^!*OrQM5PZtO98k*l zfhodlRgp)1cS6IAgf=s+5fk;f_SD-})s1(;Cp+a$AYl0Az&}a=<=8RbECtpqgY(Mk z4q^%>;eo@wF-Npfobig>5KCI|e4_3A*r18Wgmr6izqZQUI;+RH)8R6(XGt#iWHjpz zWuVAC!DqSoDat4iH%@Z<|7z^JzuA2M{}U9gnTo1WBUbF$+G-@UR3oUO_Nvt?qE->F zwnXgR+G3X4wSv;tDm8*qYVWP6y*_u}f57*A&&iLu&$+MbJg@P1KF0k+=I80%vss41 z|6~thUKQ7?S~?^>^0X$TukJKG47m?q;{yo%P- zC{-Z&Jn`4RcY|oNLDr>v^IW4-s4uuZ>uA9$ChXpCy*u9<^P_>2kWZrgj48c%0hF_o ze-6A=(E3f0eiO^2i*8VGr84BSX^6hp*mCvK`#0y=X@<+`6_A+ZWz!f*lhQs|-m%4P zEI*)*8b3EKBHDtOD($E=f4xTy)DJ5Za$F?0>*k;Bv?;Xt6)#fuF2l21s3G^%#iVA^ znoFy4#~;|ne$0jjo*ZnZePi6V@>@)*@yzG_jcWt+(F_jJqnOPMj}F!`E%s-L->x}u zN!cRkoOY*2zfdhNou#`U{h6(qAL|RG7!a$Q@T@sS^?y09be~{qFyXlSVy4w?Im4$c zgmV#sca(-gz+fFLqgu#{KHCn+m@FP|&4^`E(?GymKTLUtBueXN>x#luo$JgIm@?&^ zw((l|%qkgWRTYL8-vY4u#9OU)0jacstq+y-n1AA_p^_=dLQ#&l7w0|$$HOoO6s&+H zIkJ~$?3-b}m{F{Zi#+_esmo&G7CcLe8$u+3;GutInp8i#W3t+VH~{PXHD9v&9{?k3 zrgS12#R+&4%77aen|+>z5rgWHH66Ea^XXROaydA*$`i~BzSXKFW&3TXiSnWEEy+hq zpbu5)1Z2TqjdaZ5eZyyh28qi_#@m9;&)FK-WE>xCHnp0#P5XBTPxKJXHAoLQK6 zZL!y);NulO;-LR&(Q$o*+jT%yX+hy1S1;R~+dp`g4j9Kl-&||8jRZJG0cUuZeiLOR zZUWkvhT~prX5Q=1YSFfb6Tf3W%FewBXH3c{`4u+$UnaN@SU-uL-h)2%npa z;YE)sBg$_O#rPf<#@#AoZ@Bj%4Sy6%!HJe4uK()r^X(11{U{4$CdOjleGVu+&|dl! zxI8t(JMN5s5-y)Q9E`bqJB$!;L9_`m{4B3=Dapp+490;AwJPt|Hm0pY17y}ky98k^%r8zK>{Rzihz&MUf! zOr6R(0jtNwwE3m!bVV;pEELU3X@-x>_cT+XF{j00JUCg=~h=fJPW^Ip>^gGg#n2oj{Ff*HJM z!2Ag3mF_w31^AU%kn$>AY=)QcKx)WaF`esPj)=&W?1F4~lqFp&2b;AU2#sRI34<=39Gt&6v4$j?Q?yS1J*G(CBa^T{ zavZGS&SxhhO>auB>+v)cM!i&Nzk1_^ zTkS=+31|=HIMHnWtt5(dOgAW+ssP01`QG+!P7#eJgf4L56q>`Yr#96%fMP#oyail0 z+8c24RHL8=zql%FvoK{mt-{wU(*7gde4Zu#1>)GpLtLr9p`Rk|8ova~zdxy0nqb`2 zZ+i$N);K&|M*_8vpLD`lRlabGlzrm0pQJmg%exGZ7BoxjbAW0d*x5f(f$Nl~rIsnf z^#1K4nfeHrHx}(;&F>jVNGp|3L8sC{DYFM{j#dK@zJm?XbIR>qs7L`C8I6xqukNp! zYl59L(67}Y=xc0?tKr6yCy{YxvIki0F+!P|_Qtmshf_vYm`napYKG`IH*>+stZ=q@w$+b6H&QaQ^0hev z1)%u?sbm8)Rb#n^Oi?y1n5pclXQU`*2#h;qMOTWfO$rg@n-#pSZo0tSE_p^!pno@~ z+^RnoIXJBf6a0J=I6m!e7VNFzOy&AR3k%|00BgNaLt-X*qzqL<>!pVqE= zMzA)lUbu?S@j=Ia0fIxgOMv!cBVN5XaOkRmj48~*kWVyW9pNcE>7wVI0018V>l8A{ zlWA#%17PQ0$j1Nk*LW2wRV8%e8J*0mjmOWEWOn?LEc<(SzWT1`;DzzK2o>=PK z;mMM56_164ZWpS3a>!CWHsDC~@n07%dwd6+uRkoWk07@+R|wMTYj-aa>azX%&E!GN z6wx-yX)EK@cGff*WOK~m*dJ|LXIB73ru17}#oLrUYc^HiF)Gy`202IbrF~nL9@HFq7+0^XsY+_D?WTIFT8zl=FM6?LVumPeFi^7c%sXz&;N2&B`TDhQgqm8~1$ zn%t}2X251%=k`8!L=CORxBj6L@OEVE8Q$nlpJH&J%C7gRt+p~tNle>Pj28LNSPe3o z$N8(!O(E$o$1BJEwU9vkKH}Im{5s5fRpXNedPx88*IeG}mMCSfVh0(Mm#_C*%sk7U z)omnbBMv6KBb=v}C)AiM+#Im2wk4v2{v%xUsY@!|ASb|T8rpoZmf*Ucvmp`@oe_n|iBue#7c43g*r$ ziEZ&W+832qjB|fS7a1Y^%*X+f`JPuNW2LPS1MI;*x7Y}uZmx#IX~m4lvhY`XQMBhm zoc7Y_{dI97xoP+7bD&mmiWJ0pangqfaUy)WP+n6mPQK5=ot1Gd@g&dkY2InYYz}UP ziY|~1k|RY)>-28)cqeAhlrZ#G#}H27$qxvBpDSnLTFP+~SAZbzi)XLgy1CpYI}8>y z=e5v{3QV!G4L_^@b@%PZSKuGLcY5k%qb1oOzTo-=(W_j(%MO5~|q4|ug z`_%FsWS`7aTE=cfba&AH{$bSX7a}`lr+0fTa=u6|TFJUFqd=I-`~La7$t$_uFJu^b zDvQ_&*By?t-e$uT^Vco86T>Wvvux%O`N*P@+OD)UdSK!8YZSKjc8iE*JgeOqSLlir z8H)Fc)79)_w9xL4Y{PWA#isxejT!y{6yTUlJjV`8Cl$)g&VA>QfX!GoQ297yvT{bKLc9< z$PhL43s{4;S^k|M_Q2DF-X1+@M2nCe@rLQUKp`3heTQvw_sUo?>$d4M_s@P8oG&yf z!Z#B(NxZ4Gw&ezY6|zvNh=fZI#QP42l9p=^>< z;pDw^DiCgWM`A{u)sCz6HO^3teHnBy4rDhJ<}_~KHm(KC%(m(q6RK!TkkEjPQrt1X&85ozf!qeu%3C4a$(pbqU?xZ-ssWG@ zth>EMjpDZ*`|_s3GDppd$-8;%*ST}B_T1zg&fLn39B+P%0ow5xx~FbB~&$|aMbfk_DCE98n>b~cRT9E_)mnL zm2w$1^5ZqDH+-$d^((H|4VwZzYV#?v>LJ8W$LEBbPFKFjw%G+r)h*AZAF2Mi8JDdqzT(* z3o^U`IlJ@gXlGowB;qkkfs)AE)Fn^6uf&xI060y`JN#i>j4tE4aC|~!I3yWe(2aur zjsqHhaSeSHxXV^OfDP*kUX6i^{3E)x#&GW-NHT6i>nJ$nbf=*pOGZt*4LJXSM2*vd z*y^Z5f)r)YSJJaY4ag*dLv!?DleV1|CAAxsvG1*DCp}C!>1e3gNYwD!HT28)wD}Wf zOH}G56ZUa=N%=cLM2eZ0b(eQI2>fQF@$0VcFgBkS5Bq8)YPZ1-$?|vL^Q53I*nc^H zyYTi{k|0#iO`0L>jcfczXL)DqA&9YqzR?nl0>HlYj)*w3R6_(pn1FKrTg}J>BbZ>Mr;?!E0Rkp3D*^s^cf=Fgf6T@jR16x zX54**GasNPrx~I_ZoK|2Od=}4@i>&ZoVO{aSQHg@n)d|oW+Zt-l7I4Fjx_oZkcbM~ zVv0VNSR@}cH9>^F3%m$%YdY z!9hb$w7L<44Y~c}?nplvOivx+C(#P#9a5=jG_2_v8E4D@ags65FzlE`Eaiv(i z|C4J-%%Wr%O3^d?`8(j{X88LjP@dM3BuM`6d$L*guB$p&aq0?7!) zi1`cwK^AZbpDl>+VWs1*281Mn{YXu>a8aQ`cmj-2K^;-iB{nzh&-(|i>9N4hBxjI( z?}o%{0;?D=TC{s4J}yK99XOPsnOFGES}!;C<^3deh_$|(uR;Me(pe703MKJVV1KR? zU0mS=Wj-9scEeOx)f`^}=&E)5t9yEjP_P)rj#*v0m#!FyVXPV~lj}~^fHas-Xf}7< zfCyw4^T(AxmpX|I&74TUwK1x2m*ZBhq1o7M3AFpfezK3hVmdOl>V5@kLHTDQ<_Waz zq^Fq40h4MjI;9uL1>v{Zz;xl54$197B5H$wUuHqn=g(gtKFdoS8oWuX7sN(WEPcu( zHuq_QlYajAw;!Z-ZSosEifF z;%B>hylShfU49jLiwY_GtNkn)#tqWMkjHU9kPTJlvA)I_ZojztmVgz*(so(jy;$^C z)F>5#e|^wq!tv8s_WLLz@#yehhcm|J&CRQ$U{T1qO{Ty>xF<^wHTRC9m|?TD7DdFC zG|Uc>4A+!WH;227%MLEo4^T5Dfnq@fS!X#CWI!beQti)gfx_FXLxe_iyknkl|Oz=Di}!ZP#vcN0A&5#e|1r zS{c`G`hSBHgywwdVxfXweBIw_=T-`< zylU*fGZNtG5>cGsha4zQh5`>%%J=L5vA0!lLn10sMMa`PMUCA0OUr5zv1;!Iom(Q^ zH;MnamT~JK{?>;cpLtqC&xE?)eVvF*gh=9Z&dgI3H@dFE8V6Tou%R8Qp;&U=RaG^H zC~-P5i`$5Izz$)>=Fj$Gh75E+Y|!g@^n4Shy4_tye z10Rm#yCXnGl2X>{f4th^Wy(Zw2mv#d7y5>vyOen<0x+D^7uhFv>(p={+H@nq%?+S& zOAb2$b80g$#fsL>6Zhty{&2+>d-=wZ43fJvxj&T}=~m%oCyN!TX;cefh*3f3q0#>l zzKgLcmU%z@18}gYxub}G9I)!;S=Q(GKe9i1Ki!X5PEW8<+-_nTZ$91F$h^E*$l?V! z%gw-?8iN7!Xut@9z)_RrTd()C|z^mM95&s$I9%6VzMPqk7<*!LwlIBQRap z$!TRZSiS7LNsnCa3p^H?r~C!*!`ms_6TAT%{Uy^QQ!U|uDB*mfN228n$!IayerhI9C3|IX@>@cWI17tUf{Mitc>zOX?30_ zTKnSDVD094TG1WJ^{tl6i}K$9i+q#0?rX}S@XnXsZdVBivSCTx`f#=Q^2}rt%(kw~bLbF!7pc4Pmkh+<(MwHR;Us@hMyf zs@Q0->Noakuld5r=8I9O5>gy}dBJ7=E4MS_7Bl~~NuOClVdUjJ!)ND>d?e>`2&R{oH!>#}&KixXkw#yOWh;)pG79v-Q_=Dw6x4+{tpv~!dPFyW0FOmFvpLxW`IYmP0lDGU0BKWg?S;JIIiB-#} z6}uEH>;A7$Gam}-^Swb`!O7a44wMxzX}=%cyPFaMN_xoc8M&qEllgS3@lT8DtHnN* z7JE)!LEqt9U=6NjjNie;ikRj9q!`=scFD7kxYwX9S3M((=}6yRQ_}hS0Kyxte?|J9 zNqH^$UFk`%>BuxfR8fAFY^quo)UV2Rad{q$GPZFt*>9|K%V)yRp2$A%$sC){S9*9EgW6Vh zt&Qo(P+3+otE&NCXarR}T^UmxCn-}?O}*8p7#e8L2DANdeb JreXcx{{dNYfo1>z From 26c335fc68ddf872ee1d8d3d2632be7e2dd41105 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 10:53:21 -0400 Subject: [PATCH 18/35] Extend tests for #4388 --- netbox/dcim/tests/test_models.py | 335 ++++++++++++++++++++----------- 1 file changed, 220 insertions(+), 115 deletions(-) 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): """ From ca762588ca800283ad5f15400b403266b913f44b Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 11:59:14 -0400 Subject: [PATCH 19/35] Pretty-up cable trace template --- netbox/templates/dcim/cable_trace.html | 55 ++++++++++++++++++++------ 1 file changed, 42 insertions(+), 13 deletions(-) diff --git a/netbox/templates/dcim/cable_trace.html b/netbox/templates/dcim/cable_trace.html index fc637f9ef..1e7210e9a 100644 --- a/netbox/templates/dcim/cable_trace.html +++ b/netbox/templates/dcim/cable_trace.html @@ -48,21 +48,50 @@ {% endif %} - {% if not forloop.last %}
{% endif %} +
{% endfor %}
-
- {% if split_ends %} -

Trace Split

-

Select a termination to continue:

-
- {% else %} + {% 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 %} -
+
+ {% endif %}
{% endblock %} From 5eef6bc5274567177666a0a07684ac4ffb901d6e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 12:51:43 -0400 Subject: [PATCH 20/35] Changelog for #4388 --- docs/release-notes/version-2.8.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 3940206ac..25a992d95 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -8,7 +8,8 @@ ### Bug Fixes -* [#4361](https://github.com/netbox-community/netbox/issues/4361) - Fix Type of `connection_state` in swagger schema. +* [#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 * [#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 From b362c6a9678d93e73070703cb292f41e918d7065 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 13:41:38 -0400 Subject: [PATCH 21/35] Fixes #2994: Prevent modifying termination points of existing cable to ensure end-to-end path integrity --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/models/__init__.py | 32 +++++++++++++++++++++++++++++++ 2 files changed, 33 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 25a992d95..e29ee1aa1 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -8,6 +8,7 @@ ### 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 * [#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 * [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index 2c1940296..e0953839a 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -2070,6 +2070,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 +2112,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 From cc721efe97253e8f0e9345fd335411bad1a6de09 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 14:12:49 -0400 Subject: [PATCH 22/35] Fixes #3356: Correct Swagger schema specification for the available prefixes/IPs API endpoints --- docs/release-notes/version-2.8.md | 1 + netbox/ipam/api/serializers.py | 8 ++++++++ netbox/ipam/api/views.py | 13 +++++++++++++ 3 files changed, 22 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index e29ee1aa1..0f4e0ba48 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -9,6 +9,7 @@ ### 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 * [#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 * [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view diff --git a/netbox/ipam/api/serializers.py b/netbox/ipam/api/serializers.py index 4e596631d..f5de2f509 100644 --- a/netbox/ipam/api/serializers.py +++ b/netbox/ipam/api/serializers.py @@ -183,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 @@ -246,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): From ada55dfdfbc16e8d5663126ebeddb289ce1bb0df Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 14:50:15 -0400 Subject: [PATCH 23/35] Fixes #4510: Enforce address family for device primary IPv4/v6 addresses --- docs/release-notes/version-2.8.md | 1 + netbox/dcim/models/__init__.py | 14 ++++++++++---- 2 files changed, 11 insertions(+), 4 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 0f4e0ba48..b0f5754b2 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -14,6 +14,7 @@ * [#4388](https://github.com/netbox-community/netbox/issues/4388) - Fix detection of connected endpoints when connecting rear ports * [#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 --- diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index e0953839a..b90131ca5 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_ip4} 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 From 131d2c97ca7c6b246b4b5d08f85e502e4fd4a77a Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 21 Apr 2020 16:13:34 -0400 Subject: [PATCH 24/35] Fixes #4336: Ensure interfaces without a subinterface ID are ordered before subinterface zero --- docs/release-notes/version-2.8.md | 1 + .../0105_interface_name_collation.py | 18 ++++++++++ netbox/dcim/models/device_components.py | 3 +- netbox/dcim/tests/test_natural_ordering.py | 6 ++++ netbox/utilities/ordering.py | 2 +- netbox/utilities/query_functions.py | 9 +++++ netbox/utilities/tests/test_ordering.py | 35 ++++++++++--------- 7 files changed, 56 insertions(+), 18 deletions(-) create mode 100644 netbox/dcim/migrations/0105_interface_name_collation.py create mode 100644 netbox/utilities/query_functions.py diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index b0f5754b2..e9c5f0f78 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -10,6 +10,7 @@ * [#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 +* [#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 * [#4489](https://github.com/netbox-community/netbox/issues/4489) - Fix display of parent/child role on device type view 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/device_components.py b/netbox/dcim/models/device_components.py index f3cf0e3c8..8c79d89d8 100644 --- a/netbox/dcim/models/device_components.py +++ b/netbox/dcim/models/device_components.py @@ -16,6 +16,7 @@ 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 @@ -676,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/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/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: From 97b8e73716c8d6a76f9e11eb370d6273050a6889 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 11:15:39 -0400 Subject: [PATCH 25/35] Introduce model-specific bulk create forms for device components --- netbox/dcim/forms.py | 230 +++++++++++++++++++++++--------------- netbox/dcim/views.py | 12 +- netbox/utilities/forms.py | 10 ++ 3 files changed, 155 insertions(+), 97 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 48b0de903..c51332b76 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 * @@ -2299,31 +2300,6 @@ class DeviceBulkAddComponentForm(BootstrapMixin, forms.Form): ) -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 - ) - - # # Console ports # @@ -2375,6 +2351,15 @@ class ConsolePortCreateForm(BootstrapMixin, forms.Form): ) +class ConsolePortBulkCreateForm( + form_from_model(ConsolePort, ['type', 'description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False + ) + + class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsolePort.objects.all(), @@ -2462,6 +2447,15 @@ class ConsoleServerPortCreateForm(BootstrapMixin, forms.Form): ) +class ConsoleServerPortBulkCreateForm( + form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False + ) + + class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=ConsoleServerPort.objects.all(), @@ -2573,6 +2567,15 @@ class PowerPortCreateForm(BootstrapMixin, forms.Form): ) +class PowerPortBulkCreateForm( + form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False + ) + + class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=PowerPort.objects.all(), @@ -2700,6 +2703,15 @@ 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 +): + tags = TagField( + required=False + ) + + class PowerOutletCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -2985,71 +2997,13 @@ 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.', - } +class InterfaceBulkCreateForm( + form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False ) - 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 InterfaceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): @@ -3175,6 +3129,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 # @@ -3331,6 +3352,15 @@ class FrontPortCSVForm(forms.ModelForm): self.fields['rear_port'].queryset = RearPort.objects.none() +# class FrontPortBulkCreateForm( +# form_from_model(FrontPort, ['type', 'description', 'tags']), +# DeviceBulkAddComponentForm +# ): +# tags = TagField( +# required=False +# ) + + class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=FrontPort.objects.all(), @@ -3436,6 +3466,15 @@ class RearPortCSVForm(forms.ModelForm): fields = RearPort.csv_headers +# class RearPortBulkCreateForm( +# form_from_model(RearPort, ['type', 'positions', 'description', 'tags']), +# DeviceBulkAddComponentForm +# ): +# tags = TagField( +# required=False +# ) + + class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=RearPort.objects.all(), @@ -4011,6 +4050,15 @@ class PopulateDeviceBayForm(BootstrapMixin, forms.Form): ).exclude(pk=device_bay.device.pk) +class DeviceBayBulkCreateForm( + form_from_model(DeviceBay, ['description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False + ) + + class DeviceBayBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): pk = forms.ModelMultipleChoiceField( queryset=DeviceBay.objects.all(), diff --git a/netbox/dcim/views.py b/netbox/dcim/views.py index c10a821dc..cc8f285c8 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1930,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 @@ -1942,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 @@ -1954,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 @@ -1966,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 @@ -1978,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 @@ -1990,7 +1990,7 @@ class DeviceBulkAddDeviceBayView(PermissionRequiredMixin, BulkComponentCreateVie 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 diff --git a/netbox/utilities/forms.py b/netbox/utilities/forms.py index fd528f827..d787b2d67 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,15 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) +def form_from_model(model, fields): + """ + Return a Form class with the specified fields from a model. + """ + form_fields = fields_for_model(model, fields=fields) + + return type('FormFromModel', (forms.Form,), form_fields) + + # # Widgets # From 62cdf0d92864f6b7a8f570236008f495b6323c1e Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 11:26:04 -0400 Subject: [PATCH 26/35] Add bulk creation view for rear ports --- netbox/dcim/forms.py | 136 ++++++++++++------------- netbox/dcim/urls.py | 2 +- netbox/dcim/views.py | 24 +++++ netbox/templates/dcim/device_list.html | 1 + 4 files changed, 94 insertions(+), 69 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index c51332b76..765ad699b 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -3304,6 +3304,50 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form): } +# class FrontPortBulkCreateForm( +# form_from_model(FrontPort, ['type', 'description', 'tags']), +# DeviceBulkAddComponentForm +# ): +# tags = TagField( +# required=False +# ) + + +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 + ) + + class FrontPortCSVForm(forms.ModelForm): device = FlexibleModelChoiceField( queryset=Device.objects.all(), @@ -3352,50 +3396,6 @@ class FrontPortCSVForm(forms.ModelForm): self.fields['rear_port'].queryset = RearPort.objects.none() -# class FrontPortBulkCreateForm( -# form_from_model(FrontPort, ['type', 'description', 'tags']), -# DeviceBulkAddComponentForm -# ): -# tags = TagField( -# required=False -# ) - - -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 # @@ -3448,31 +3448,13 @@ 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.', - } +class RearPortBulkCreateForm( + form_from_model(RearPort, ['type', 'positions', 'description', 'tags']), + DeviceBulkAddComponentForm +): + tags = TagField( + required=False ) - type = CSVChoiceField( - choices=PortTypeChoices, - ) - - class Meta: - model = RearPort - fields = RearPort.csv_headers - - -# class RearPortBulkCreateForm( -# form_from_model(RearPort, ['type', 'positions', 'description', 'tags']), -# DeviceBulkAddComponentForm -# ): -# tags = TagField( -# required=False -# ) class RearPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): @@ -3510,6 +3492,24 @@ 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 + + # # Cables # 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 cc8f285c8..9ca4c2edc 100644 --- a/netbox/dcim/views.py +++ b/netbox/dcim/views.py @@ -1986,6 +1986,30 @@ 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 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 %} From e975f1b216b3307029572144226b6cd003869905 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 11:47:26 -0400 Subject: [PATCH 27/35] Update device component bulk edit forms to use form_from_model() --- netbox/dcim/forms.py | 522 ++++++++++++++++---------------------- netbox/utilities/forms.py | 6 +- 2 files changed, 229 insertions(+), 299 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 765ad699b..7e57bb723 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2360,20 +2360,16 @@ class ConsolePortBulkCreateForm( ) -class ConsolePortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +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 = ( @@ -2456,20 +2452,16 @@ class ConsoleServerPortBulkCreateForm( ) -class ConsoleServerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +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 = [ @@ -2576,30 +2568,16 @@ class PowerPortBulkCreateForm( ) -class PowerPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +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 = ( @@ -2712,6 +2690,54 @@ class PowerOutletBulkCreateForm( ) +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(), @@ -2762,65 +2788,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 # @@ -3006,7 +2973,12 @@ class InterfaceBulkCreateForm( ) -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() @@ -3017,45 +2989,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, @@ -3313,20 +3246,16 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form): # ) -class FrontPortBulkEditForm(BootstrapMixin, AddRemoveTagsForm, BulkEditForm): +class FrontPortBulkEditForm( + form_from_model(FrontPort, ['type', 'description']), + 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 = [ @@ -3457,20 +3386,16 @@ class RearPortBulkCreateForm( ) -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 = [ @@ -3510,6 +3435,146 @@ class RearPortCSVForm(forms.ModelForm): 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 # @@ -3993,145 +4058,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 DeviceBayBulkCreateForm( - form_from_model(DeviceBay, ['description', 'tags']), - DeviceBulkAddComponentForm -): - tags = TagField( - required=False - ) - - -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/utilities/forms.py b/netbox/utilities/forms.py index d787b2d67..d95c86527 100644 --- a/netbox/utilities/forms.py +++ b/netbox/utilities/forms.py @@ -126,9 +126,13 @@ def add_blank_choice(choices): def form_from_model(model, fields): """ - Return a Form class with the specified fields from a model. + 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) From 6a61f0911dfc8183444b428fc971acec4766eed6 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 12:09:40 -0400 Subject: [PATCH 28/35] Update InterfaceBulkCreateForm for VMs --- netbox/virtualization/forms.py | 21 +++++++-------------- netbox/virtualization/views.py | 2 +- 2 files changed, 8 insertions(+), 15 deletions(-) diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index f4c2a36ec..a8232cbb5 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 @@ -828,23 +829,15 @@ class VirtualMachineBulkAddComponentForm(BootstrapMixin, forms.Form): ) -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, + tags = TagField( 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 From 7b50f2b0eb7ac0071d21c7ab47ec37e23d353161 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 14:05:27 -0400 Subject: [PATCH 29/35] Fix tag assignment when bulk creating components --- netbox/dcim/forms.py | 33 +++++++++++----------------- netbox/utilities/views.py | 39 ++++++++++++++++++++-------------- netbox/virtualization/forms.py | 8 ++++--- 3 files changed, 40 insertions(+), 40 deletions(-) diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 7e57bb723..29710971e 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2299,6 +2299,11 @@ class DeviceBulkAddComponentForm(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')) + # # Console ports @@ -2355,9 +2360,7 @@ class ConsolePortBulkCreateForm( form_from_model(ConsolePort, ['type', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class ConsolePortBulkEditForm( @@ -2447,9 +2450,7 @@ class ConsoleServerPortBulkCreateForm( form_from_model(ConsoleServerPort, ['type', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class ConsoleServerPortBulkEditForm( @@ -2563,9 +2564,7 @@ class PowerPortBulkCreateForm( form_from_model(PowerPort, ['type', 'maximum_draw', 'allocated_draw', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class PowerPortBulkEditForm( @@ -2685,9 +2684,7 @@ class PowerOutletBulkCreateForm( form_from_model(PowerOutlet, ['type', 'feed_leg', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class PowerOutletBulkEditForm( @@ -2968,9 +2965,7 @@ class InterfaceBulkCreateForm( form_from_model(Interface, ['type', 'enabled', 'mtu', 'mgmt_only', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class InterfaceBulkEditForm( @@ -3241,9 +3236,7 @@ class FrontPortCreateForm(BootstrapMixin, forms.Form): # form_from_model(FrontPort, ['type', 'description', 'tags']), # DeviceBulkAddComponentForm # ): -# tags = TagField( -# required=False -# ) +# pass class FrontPortBulkEditForm( @@ -3381,9 +3374,7 @@ class RearPortBulkCreateForm( form_from_model(RearPort, ['type', 'positions', 'description', 'tags']), DeviceBulkAddComponentForm ): - tags = TagField( - required=False - ) + pass class RearPortBulkEditForm( 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 a8232cbb5..9ba5ff032 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -828,6 +828,11 @@ 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 InterfaceBulkCreateForm( form_from_model(Interface, ['enabled', 'mtu', 'description', 'tags']), @@ -838,6 +843,3 @@ class InterfaceBulkCreateForm( initial=VMInterfaceTypeChoices.TYPE_VIRTUAL, widget=forms.HiddenInput() ) - tags = TagField( - required=False - ) From 5ea92dda4b0253d847a2b4506c6894e392593c42 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 22 Apr 2020 14:15:41 -0400 Subject: [PATCH 30/35] Changelog for #4139 --- docs/release-notes/version-2.8.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index e9c5f0f78..a51f559e9 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -10,6 +10,7 @@ * [#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 From 11ee6f417f9739ba1c80b55092ecbd827bf6458b Mon Sep 17 00:00:00 2001 From: John Anderson Date: Wed, 22 Apr 2020 16:45:26 -0400 Subject: [PATCH 31/35] fix #4459 - Fix caching issue resulting in erroneous nested data for regions, rack groups, and tenant groups --- docs/release-notes/version-2.8.md | 16 ++++++++++++++++ netbox/netbox/settings.py | 3 +++ 2 files changed, 19 insertions(+) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index a51f559e9..b80b01e19 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -14,10 +14,26 @@ * [#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 +### 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 preform 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 (Mac-Pro.local) +### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1-dev +### lsmodels() will show available models. Use help() for more info. +>>> Region.objects.rebuild() +>>> RackGroup.objects.rebuild() +>>> TenantGroup.objects.rebuild() +``` + --- ## v2.8.0 (2020-04-13) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index ea3852711..7aafd9618 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -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'}, } From 70b8b9ecdb580b3e9bea91bcddabbeed558db96c Mon Sep 17 00:00:00 2001 From: Sander Steffann Date: Thu, 23 Apr 2020 14:10:58 +0200 Subject: [PATCH 32/35] Fix minor typo --- docs/release-notes/version-2.8.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index b80b01e19..16b61f442 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -22,7 +22,7 @@ ### 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 preform a one time operation using the NetBox shell to rebuild the correct nested relationships after upgrading: +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 From 3ece4f137f474895c3d015d295fa9671c14925f9 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 10:09:13 -0400 Subject: [PATCH 33/35] Release v2.8.1 --- docs/release-notes/version-2.8.md | 30 +++++++++++++++--------------- netbox/netbox/settings.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 16b61f442..6a7ff07df 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -2,6 +2,21 @@ ## v2.8.1 (FUTURE) +### 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) @@ -19,21 +34,6 @@ * [#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 -### 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 (Mac-Pro.local) -### Python 3.6.4 | Django 3.0.5 | NetBox 2.8.1-dev -### lsmodels() will show available models. Use help() for more info. ->>> Region.objects.rebuild() ->>> RackGroup.objects.rebuild() ->>> TenantGroup.objects.rebuild() -``` - --- ## v2.8.0 (2020-04-13) diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 7aafd9618..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.1-dev' +VERSION = '2.8.1' # Hostname HOSTNAME = platform.node() From 92343469e76463583a135d23e6e8284220f00688 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 10:11:11 -0400 Subject: [PATCH 34/35] Correct release date --- docs/release-notes/version-2.8.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/release-notes/version-2.8.md b/docs/release-notes/version-2.8.md index 6a7ff07df..e6eabf8ca 100644 --- a/docs/release-notes/version-2.8.md +++ b/docs/release-notes/version-2.8.md @@ -1,6 +1,6 @@ # NetBox v2.8 -## v2.8.1 (FUTURE) +## v2.8.1 (2020-04-23) ### Notes From e5e5725a247f9ba2dd3679b88f7273699ed77dcc Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Thu, 23 Apr 2020 10:12:56 -0400 Subject: [PATCH 35/35] Fix typo in validation error message --- netbox/dcim/models/__init__.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/models/__init__.py b/netbox/dcim/models/__init__.py index b90131ca5..096065cab 100644 --- a/netbox/dcim/models/__init__.py +++ b/netbox/dcim/models/__init__.py @@ -1529,7 +1529,7 @@ class Device(ChangeLoggedModel, ConfigContextModel, CustomFieldModel): if self.primary_ip6: if self.primary_ip6.family != 6: raise ValidationError({ - 'primary_ip6': f"{self.primary_ip4} is not an IPv6 address." + 'primary_ip6': f"{self.primary_ip6} is not an IPv6 address." }) if self.primary_ip6.interface in vc_interfaces: pass