From 93550464a9e070b4f8728be6dfd7d447d74d6152 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Fri, 5 Oct 2018 15:49:51 -0400 Subject: [PATCH 01/23] 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 cc393b833..c96503e4d 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.6' +VERSION = '2.4.7-dev' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) From d15ce65ef7d6d1e833f113eefcaf2245367c19e0 Mon Sep 17 00:00:00 2001 From: Tobias Genannt Date: Mon, 3 Sep 2018 08:11:35 +0200 Subject: [PATCH 02/23] Fix #2395: Modify only when webhooks are enabled This only adds the RQ link when the webhooks setting is enabled. --- netbox/netbox/admin.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/netbox/netbox/admin.py b/netbox/netbox/admin.py index 4c7e0f81b..34faba233 100644 --- a/netbox/netbox/admin.py +++ b/netbox/netbox/admin.py @@ -23,8 +23,9 @@ admin_site.register(User, UserAdmin) admin_site.register(Tag, TagAdmin) # Modify the template to include an RQ link if django_rq is installed (see RQ_SHOW_ADMIN_LINK) -try: - import django_rq - admin_site.index_template = 'django_rq/index.html' -except ImportError: - pass +if settings.WEBHOOKS_ENABLED: + try: + import django_rq + admin_site.index_template = 'django_rq/index.html' + except ImportError: + pass From 16b7b8064cecb1975031e408f1fc2403a9e30805 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Wed, 10 Oct 2018 09:49:35 -0400 Subject: [PATCH 03/23] Fixes #2508: Removed invalid link --- docs/core-functionality/devices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 0d3df016c..06f78cabb 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -101,7 +101,7 @@ Device bays represent the ability of a device to house child devices. For exampl A platform defines the type of software running on a device or virtual machine. This can be helpful when it is necessary to distinguish between, for instance, different feature sets. Note that two devices of the same type may be assigned different platforms: for example, one Juniper MX240 running Junos 14 and another running Junos 15. -The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. See the [API documentation](api/napalm-integration.md) for more information on NAPALM integration. +The platform model is also used to indicate which [NAPALM](https://napalm-automation.net/) driver NetBox should use when connecting to a remote device. The name of the driver along with optional parameters are stored with the platform. The assignment of platforms to devices is an optional feature, and may be disregarded if not desired. From d3d84719485161582924086ba284187450884e59 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Oct 2018 09:42:19 -0400 Subject: [PATCH 04/23] Changelog for #2515 --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 57f17a367..61cff2c30 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,11 @@ +v2.4.7 (FUTURE) + +## Bug Fixes + +* [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled + +--- + v2.4.6 (2018-10-05) ## Enhancements From f6b7f41d7a696b997b62c5678b17fb49e6a30747 Mon Sep 17 00:00:00 2001 From: Chris James Date: Tue, 16 Oct 2018 11:36:32 -0500 Subject: [PATCH 05/23] Fix "cusomizable" typo --- docs/core-functionality/devices.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/devices.md b/docs/core-functionality/devices.md index 06f78cabb..5ae599c73 100644 --- a/docs/core-functionality/devices.md +++ b/docs/core-functionality/devices.md @@ -58,7 +58,7 @@ A device is said to be full depth if its installation on one rack face prevents ## Device Roles -Devices can be organized by functional roles. These roles are fully cusomizable. For example, you might create roles for core switches, distribution switches, and access switches. +Devices can be organized by functional roles. These roles are fully customizable. For example, you might create roles for core switches, distribution switches, and access switches. --- From 182f5a8e8679e64e024cd82b121b908528428ff4 Mon Sep 17 00:00:00 2001 From: mmahacek Date: Tue, 16 Oct 2018 10:19:33 -0700 Subject: [PATCH 06/23] Expand Webhook Documentation #2347 (#2524) * #2347 - Expand Webhook Documentation Move "Install Python Packages" section up one header level. Should make Napalm/Webhook sections appear in table of contents for direct linking. * #2347 - Expand Webhook Documentation Add text for installation to link to other documentation sections with instructions. --- docs/additional-features/webhooks.md | 8 ++++++++ docs/installation/2-netbox.md | 6 +++--- 2 files changed, 11 insertions(+), 3 deletions(-) diff --git a/docs/additional-features/webhooks.md b/docs/additional-features/webhooks.md index 0e74640fa..68f342e88 100644 --- a/docs/additional-features/webhooks.md +++ b/docs/additional-features/webhooks.md @@ -4,6 +4,14 @@ A webhook defines an HTTP request that is sent to an external application when c An optional secret key can be configured for each webhook. This will append a `X-Hook-Signature` header to the request, consisting of a HMAC (SHA-512) hex digest of the request body using the secret as the key. This digest can be used by the receiver to authenticate the request's content. +## Installation + +If you are upgrading from a previous version of Netbox and want to enable the webhook feature, please follow the directions listed in the sections below. + +* [Install Redis server and djano-rq package](../installation/2-netbox/#install-python-packages) +* [Modify configuration to enable webhooks](../installation/2-netbox/#webhooks-configuration) +* [Create supervisord program to run the rqworker process](../installation/3-http-daemon/#supervisord-installation) + ## Requests The webhook POST request is structured as so (assuming `application/json` as the Content-Type): diff --git a/docs/installation/2-netbox.md b/docs/installation/2-netbox.md index 8f59adc29..ad4556383 100644 --- a/docs/installation/2-netbox.md +++ b/docs/installation/2-netbox.md @@ -71,7 +71,7 @@ Checking connectivity... done. `# chown -R netbox:netbox /opt/netbox/netbox/media/` -## Install Python Packages +# Install Python Packages Install the required Python packages using pip. (If you encounter any compilation errors during this step, ensure that you've installed all of the system dependencies listed above.) @@ -82,7 +82,7 @@ Install the required Python packages using pip. (If you encounter any compilatio !!! note If you encounter errors while installing the required packages, check that you're running a recent version of pip (v9.0.1 or higher) with the command `pip3 -V`. -### NAPALM Automation (Optional) +## NAPALM Automation (Optional) NetBox supports integration with the [NAPALM automation](https://napalm-automation.net/) library. NAPALM allows NetBox to fetch live data from devices and return it to a requester via its REST API. Installation of NAPALM is optional. To enable it, install the `napalm` package using pip or pip3: @@ -90,7 +90,7 @@ NetBox supports integration with the [NAPALM automation](https://napalm-automati # pip3 install napalm ``` -### Webhooks (Optional) +## Webhooks (Optional) [Webhooks](../data-model/extras/#webhooks) allow NetBox to integrate with external services by pushing out a notification each time a relevant object is created, updated, or deleted. Enabling the webhooks feature requires [Redis](https://redis.io/), a lightweight in-memory database. You may opt to install a Redis sevice locally (see below) or connect to an external one. From bee7dfe9f38602c1a17df51b76e1abbfb8d8af36 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 16 Oct 2018 16:41:12 -0400 Subject: [PATCH 07/23] Fixes #2514: Prevent new connections to already connected interfaces --- CHANGELOG.md | 1 + netbox/dcim/api/serializers.py | 59 ++++++++++++++++++++++------------ netbox/dcim/forms.py | 8 ++--- netbox/dcim/models.py | 57 +++++++++++++++++++++----------- netbox/dcim/tests/test_api.py | 10 +++--- 5 files changed, 87 insertions(+), 48 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 61cff2c30..eeb2749cb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.7 (FUTURE) ## Bug Fixes +* [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled --- diff --git a/netbox/dcim/api/serializers.py b/netbox/dcim/api/serializers.py index d0634e040..b0a1628de 100644 --- a/netbox/dcim/api/serializers.py +++ b/netbox/dcim/api/serializers.py @@ -472,10 +472,14 @@ class ConsoleServerPortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedConsoleServerPortSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleserverport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = ConsoleServerPort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return hasattr(obj, 'connected_console') and obj.connected_console is not None # @@ -495,10 +499,14 @@ class ConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedConsolePortSerializer(TaggitSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:consoleport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = ConsolePort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return obj.cs_port is not None # @@ -518,10 +526,14 @@ class PowerOutletSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedPowerOutletSerializer(WritableNestedSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:poweroutlet-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = PowerOutlet - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return hasattr(obj, 'connected_port') and obj.connected_port is not None # @@ -541,23 +553,43 @@ class PowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): class NestedPowerPortSerializer(TaggitSerializer, ValidatedModelSerializer): url = serializers.HyperlinkedIdentityField(view_name='dcim-api:powerport-detail') device = NestedDeviceSerializer(read_only=True) + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = PowerPort - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] + + def get_is_connected(self, obj): + return obj.power_outlet is not None # # Interfaces # -class NestedInterfaceSerializer(WritableNestedSerializer): +class IsConnectedMixin(object): + """ + Provide a method for setting is_connected on Interface serializers. + """ + def get_is_connected(self, obj): + """ + Return True if the interface has a connected interface or circuit. + """ + if obj.connection: + return True + if hasattr(obj, 'circuit_termination') and obj.circuit_termination is not None: + return True + return False + + +class NestedInterfaceSerializer(IsConnectedMixin, WritableNestedSerializer): device = NestedDeviceSerializer(read_only=True) url = serializers.HyperlinkedIdentityField(view_name='dcim-api:interface-detail') + is_connected = serializers.SerializerMethodField(read_only=True) class Meta: model = Interface - fields = ['id', 'url', 'device', 'name'] + fields = ['id', 'url', 'device', 'name', 'is_connected'] class InterfaceNestedCircuitSerializer(serializers.ModelSerializer): @@ -587,7 +619,7 @@ class InterfaceVLANSerializer(WritableNestedSerializer): fields = ['id', 'url', 'vid', 'name', 'display_name'] -class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): +class InterfaceSerializer(TaggitSerializer, IsConnectedMixin, ValidatedModelSerializer): device = NestedDeviceSerializer() form_factor = ChoiceField(choices=IFACE_FF_CHOICES, required=False) lag = NestedInterfaceSerializer(required=False, allow_null=True) @@ -631,19 +663,6 @@ class InterfaceSerializer(TaggitSerializer, ValidatedModelSerializer): return super(InterfaceSerializer, self).validate(data) - def get_is_connected(self, obj): - """ - Return True if the interface has a connected interface or circuit termination. - """ - if obj.connection: - return True - try: - circuit_termination = obj.circuit_termination - return True - except CircuitTermination.DoesNotExist: - pass - return False - def get_interface_connection(self, obj): if obj.connection: context = { diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index e7fa15f7b..6b6ea1187 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1328,7 +1328,7 @@ class ConsolePortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelF label='Port', widget=APISelect( api_url='/api/dcim/console-server-ports/?device_id={{console_server}}', - disabled_indicator='connected_console', + disabled_indicator='is_connected', ) ) @@ -1419,7 +1419,7 @@ class ConsoleServerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms. label='Port', widget=APISelect( api_url='/api/dcim/console-ports/?device_id={{device}}', - disabled_indicator='cs_port' + disabled_indicator='is_connected' ) ) connection_status = forms.BooleanField( @@ -1597,7 +1597,7 @@ class PowerPortConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.ModelFor label='Outlet', widget=APISelect( api_url='/api/dcim/power-outlets/?device_id={{pdu}}', - disabled_indicator='connected_port' + disabled_indicator='is_connected' ) ) @@ -1688,7 +1688,7 @@ class PowerOutletConnectionForm(BootstrapMixin, ChainedFieldsMixin, forms.Form): label='Port', widget=APISelect( api_url='/api/dcim/power-ports/?device_id={{device}}', - disabled_indicator='power_outlet' + disabled_indicator='is_connected' ) ) connection_status = forms.BooleanField( diff --git a/netbox/dcim/models.py b/netbox/dcim/models.py index d8c392838..33885e203 100644 --- a/netbox/dcim/models.py +++ b/netbox/dcim/models.py @@ -2035,25 +2035,44 @@ class InterfaceConnection(models.Model): csv_headers = ['device_a', 'interface_a', 'device_b', 'interface_b', 'connection_status'] def clean(self): - try: - if self.interface_a == self.interface_b: - raise ValidationError({ - 'interface_b': "Cannot connect an interface to itself." - }) - if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_a': '{} is not a connectable interface type.'.format( - self.interface_a.get_form_factor_display() - ) - }) - if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'interface_b': '{} is not a connectable interface type.'.format( - self.interface_b.get_form_factor_display() - ) - }) - except ObjectDoesNotExist: - pass + + # An interface cannot be connected to itself + if self.interface_a == self.interface_b: + raise ValidationError({ + 'interface_b': "Cannot connect an interface to itself." + }) + + # Only connectable interface types are permitted + if self.interface_a.form_factor in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'interface_a': '{} is not a connectable interface type.'.format( + self.interface_a.get_form_factor_display() + ) + }) + if self.interface_b.form_factor in NONCONNECTABLE_IFACE_TYPES: + raise ValidationError({ + 'interface_b': '{} is not a connectable interface type.'.format( + self.interface_b.get_form_factor_display() + ) + }) + + # Prevent the A side of one connection from being the B side of another + interface_a_connections = InterfaceConnection.objects.filter( + Q(interface_a=self.interface_a) | + Q(interface_b=self.interface_a) + ).exclude(pk=self.pk) + if interface_a_connections.exists(): + raise ValidationError({ + 'interface_a': "This interface is already connected." + }) + interface_b_connections = InterfaceConnection.objects.filter( + Q(interface_a=self.interface_b) | + Q(interface_b=self.interface_b) + ).exclude(pk=self.pk) + if interface_b_connections.exists(): + raise ValidationError({ + 'interface_b': "This interface is already connected." + }) def to_csv(self): return ( diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index c227179f4..04952a4d4 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -1955,7 +1955,7 @@ class ConsolePortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_consoleport(self): @@ -2070,7 +2070,7 @@ class ConsoleServerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_consoleserverport(self): @@ -2181,7 +2181,7 @@ class PowerPortTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_powerport(self): @@ -2296,7 +2296,7 @@ class PowerOutletTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_poweroutlet(self): @@ -2432,7 +2432,7 @@ class InterfaceTest(APITestCase): self.assertEqual( sorted(response.data['results'][0]), - ['device', 'id', 'name', 'url'] + ['device', 'id', 'is_connected', 'name', 'url'] ) def test_create_interface(self): From ad6500e4f2a62b6178bf02ab3bb6735e3bb8ad90 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 22 Oct 2018 11:23:37 -0400 Subject: [PATCH 08/23] Fixes #2526: Bump paramiko and pycryptodome requirements due to vulnerability --- requirements.txt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/requirements.txt b/requirements.txt index b3bee6b6d..d3fc5a561 100644 --- a/requirements.txt +++ b/requirements.txt @@ -14,10 +14,10 @@ Markdown==2.6.11 natsort==5.3.3 ncclient==0.6.0 netaddr==0.7.19 -paramiko==2.4.1 +paramiko==2.4.2 Pillow==5.2.0 psycopg2-binary==2.7.5 py-gfm==0.1.3 -pycryptodome==3.6.4 +pycryptodome==3.6.6 xmltodict==0.11.0 From cfbf5b65d872b940432d6990f3959cdf6765ba0b Mon Sep 17 00:00:00 2001 From: knobix <43905002+knobix@users.noreply.github.com> Date: Mon, 5 Nov 2018 21:33:10 +0100 Subject: [PATCH 09/23] Update models.py (#2502) Fix the handling of shared IPs (VIP, VRRF, etc.) when unique IP space enforcement is set. Add parentheses for the logical OR-statement to make the evaluation valid. Fixes: #2501 --- netbox/ipam/models.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/netbox/ipam/models.py b/netbox/ipam/models.py index f9170cd58..ef3bc6c30 100644 --- a/netbox/ipam/models.py +++ b/netbox/ipam/models.py @@ -596,11 +596,11 @@ class IPAddress(ChangeLoggedModel, CustomFieldModel): if self.address: # Enforce unique IP space (if applicable) - if self.role not in IPADDRESS_ROLES_NONUNIQUE and ( + if self.role not in IPADDRESS_ROLES_NONUNIQUE and (( self.vrf is None and settings.ENFORCE_GLOBAL_UNIQUE ) or ( self.vrf and self.vrf.enforce_unique - ): + )): duplicate_ips = self.get_duplicates() if duplicate_ips: raise ValidationError({ From 9e5e17ebbfa4bd238f7d1a67e73421384c85e1ba Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Nov 2018 15:34:39 -0500 Subject: [PATCH 10/23] Changelog for #2501 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index eeb2749cb..b7507840f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.7 (FUTURE) ## Bug Fixes +* [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled From 78f54666f4df8714bc2ba601b0e77d1df3d60fa6 Mon Sep 17 00:00:00 2001 From: Daniel Sheppard Date: Mon, 5 Nov 2018 14:37:52 -0600 Subject: [PATCH 11/23] Fixes 2427: Added filtering interfaces by vlan id(vlan=#) and vlan pk(vlan_id=#) (#2521) --- netbox/dcim/filters.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index c81af4478..efc71e5b4 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -635,6 +635,14 @@ class InterfaceFilter(django_filters.FilterSet): tag = django_filters.CharFilter( name='tags__slug', ) + vlan_id = django_filters.CharFilter( + method='filter_vlan_by_pk', + name='vlan_pk', + ) + vlan = django_filters.CharFilter( + method='filter_vlan_by_id', + name='vid', + ) class Meta: model = Interface @@ -649,6 +657,12 @@ class InterfaceFilter(django_filters.FilterSet): except Device.DoesNotExist: return queryset.none() + def filter_vlan_by_pk(self, queryset, name, value): + return queryset.filter(Q(untagged_vlan_id=value) | Q(tagged_vlans=value)) + + def filter_vlan_by_id(self, queryset, name, value): + return queryset.filter(Q(untagged_vlan_id__vid=value) | Q(tagged_vlans__vid=value)) + def filter_type(self, queryset, name, value): value = value.strip().lower() return { From 703b2e2abe952ca6a4894943145a99b69a902647 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Nov 2018 15:40:48 -0500 Subject: [PATCH 12/23] Changelog for #2427 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index b7507840f..1436875e5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.7 (FUTURE) ## Bug Fixes +* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID * [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled From 8e9539c1964d7853661e344124866a890c6c6326 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Nov 2018 15:45:21 -0500 Subject: [PATCH 13/23] Filter cleanup --- netbox/dcim/filters.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index efc71e5b4..424e99b59 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -636,12 +636,12 @@ class InterfaceFilter(django_filters.FilterSet): name='tags__slug', ) vlan_id = django_filters.CharFilter( - method='filter_vlan_by_pk', - name='vlan_pk', + method='filter_vlan_id', + label='Assigned VLAN' ) vlan = django_filters.CharFilter( - method='filter_vlan_by_id', - name='vid', + method='filter_vlan', + label='Assigned VID' ) class Meta: @@ -657,11 +657,23 @@ class InterfaceFilter(django_filters.FilterSet): except Device.DoesNotExist: return queryset.none() - def filter_vlan_by_pk(self, queryset, name, value): - return queryset.filter(Q(untagged_vlan_id=value) | Q(tagged_vlans=value)) + def filter_vlan_id(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id=value) | + Q(tagged_vlans=value) + ) - def filter_vlan_by_id(self, queryset, name, value): - return queryset.filter(Q(untagged_vlan_id__vid=value) | Q(tagged_vlans__vid=value)) + def filter_vlan(self, queryset, name, value): + value = value.strip() + if not value: + return queryset + return queryset.filter( + Q(untagged_vlan_id__vid=value) | + Q(tagged_vlans__vid=value) + ) def filter_type(self, queryset, name, value): value = value.strip().lower() From 3d780fca0184ed84fff9461d49aff943fc769b42 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Nov 2018 16:10:01 -0500 Subject: [PATCH 14/23] Fixes #2528: Enable creating circuit terminations with interface assignment via API --- CHANGELOG.md | 1 + netbox/circuits/api/serializers.py | 6 +-- netbox/circuits/tests/test_api.py | 62 ++++++++++++++++++++++-------- 3 files changed, 50 insertions(+), 19 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1436875e5..e24dec5b2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ v2.4.7 (FUTURE) * [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled +* [#2528](https://github.com/digitalocean/netbox/issues/2528) - --- diff --git a/netbox/circuits/api/serializers.py b/netbox/circuits/api/serializers.py index 739fbf8ff..c19ab2fce 100644 --- a/netbox/circuits/api/serializers.py +++ b/netbox/circuits/api/serializers.py @@ -4,8 +4,8 @@ from rest_framework import serializers from taggit_serializer.serializers import TaggitSerializer, TagListSerializerField from circuits.constants import CIRCUIT_STATUS_CHOICES -from circuits.models import Provider, Circuit, CircuitTermination, CircuitType -from dcim.api.serializers import NestedSiteSerializer, InterfaceSerializer +from circuits.models import Circuit, CircuitTermination, CircuitType, Provider +from dcim.api.serializers import NestedInterfaceSerializer, NestedSiteSerializer from extras.api.customfields import CustomFieldModelSerializer from tenancy.api.serializers import NestedTenantSerializer from utilities.api import ChoiceField, ValidatedModelSerializer, WritableNestedSerializer @@ -87,7 +87,7 @@ class NestedCircuitSerializer(WritableNestedSerializer): class CircuitTerminationSerializer(ValidatedModelSerializer): circuit = NestedCircuitSerializer() site = NestedSiteSerializer() - interface = InterfaceSerializer(required=False, allow_null=True) + interface = NestedInterfaceSerializer(required=False, allow_null=True) class Meta: model = CircuitTermination diff --git a/netbox/circuits/tests/test_api.py b/netbox/circuits/tests/test_api.py index e6c98068f..bcaf2dee4 100644 --- a/netbox/circuits/tests/test_api.py +++ b/netbox/circuits/tests/test_api.py @@ -5,7 +5,7 @@ from rest_framework import status from circuits.constants import CIRCUIT_STATUS_ACTIVE, TERM_SIDE_A, TERM_SIDE_Z from circuits.models import Circuit, CircuitTermination, CircuitType, Provider -from dcim.models import Site +from dcim.models import Device, DeviceRole, DeviceType, Interface, Manufacturer, Site from extras.constants import GRAPH_TYPE_PROVIDER from extras.models import Graph from utilities.testing import APITestCase @@ -330,21 +330,44 @@ class CircuitTerminationTest(APITestCase): super(CircuitTerminationTest, self).setUp() + 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') + manufacturer = Manufacturer.objects.create(name='Test Manufacturer 1', slug='test-manufacturer-1') + devicetype = DeviceType.objects.create( + manufacturer=manufacturer, model='Test Device Type 1', slug='test-device-type-1', is_network_device=True + ) + devicerole = DeviceRole.objects.create( + name='Test Device Role 1', slug='test-device-role-1', color='ff0000' + ) + device1 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 1', site=self.site1 + ) + device2 = Device.objects.create( + device_type=devicetype, device_role=devicerole, name='Test Device 2', site=self.site2 + ) + self.interface1 = Interface.objects.create(device=device1, name='Test Interface 1') + self.interface2 = Interface.objects.create(device=device2, name='Test Interface 2') + self.interface3 = Interface.objects.create(device=device1, name='Test Interface 3') + self.interface4 = Interface.objects.create(device=device2, name='Test Interface 4') + self.interface5 = Interface.objects.create(device=device1, name='Test Interface 5') + self.interface6 = Interface.objects.create(device=device2, name='Test Interface 6') + provider = Provider.objects.create(name='Test Provider', slug='test-provider') circuittype = CircuitType.objects.create(name='Test Circuit Type', slug='test-circuit-type') self.circuit1 = Circuit.objects.create(cid='TEST0001', provider=provider, type=circuittype) self.circuit2 = Circuit.objects.create(cid='TEST0002', provider=provider, type=circuittype) self.circuit3 = Circuit.objects.create(cid='TEST0003', provider=provider, type=circuittype) - 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.circuittermination1 = CircuitTermination.objects.create( - circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface1, port_speed=1000000 ) self.circuittermination2 = CircuitTermination.objects.create( - circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + circuit=self.circuit1, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface2, port_speed=1000000 ) self.circuittermination3 = CircuitTermination.objects.create( - circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, port_speed=1000000 + circuit=self.circuit2, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface3, port_speed=1000000 + ) + self.circuittermination4 = CircuitTermination.objects.create( + circuit=self.circuit2, term_side=TERM_SIDE_Z, site=self.site2, interface=self.interface4, port_speed=1000000 ) def test_get_circuittermination(self): @@ -359,14 +382,15 @@ class CircuitTerminationTest(APITestCase): url = reverse('circuits-api:circuittermination-list') response = self.client.get(url, **self.header) - self.assertEqual(response.data['count'], 3) + self.assertEqual(response.data['count'], 4) def test_create_circuittermination(self): data = { - 'circuit': self.circuit1.pk, - 'term_side': TERM_SIDE_Z, - 'site': self.site2.pk, + 'circuit': self.circuit3.pk, + 'term_side': TERM_SIDE_A, + 'site': self.site1.pk, + 'interface': self.interface5.pk, 'port_speed': 1000000, } @@ -374,31 +398,37 @@ class CircuitTerminationTest(APITestCase): response = self.client.post(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_201_CREATED) - self.assertEqual(CircuitTermination.objects.count(), 4) + self.assertEqual(CircuitTermination.objects.count(), 5) circuittermination4 = CircuitTermination.objects.get(pk=response.data['id']) self.assertEqual(circuittermination4.circuit_id, data['circuit']) self.assertEqual(circuittermination4.term_side, data['term_side']) self.assertEqual(circuittermination4.site_id, data['site']) + self.assertEqual(circuittermination4.interface_id, data['interface']) self.assertEqual(circuittermination4.port_speed, data['port_speed']) def test_update_circuittermination(self): + circuittermination5 = CircuitTermination.objects.create( + circuit=self.circuit3, term_side=TERM_SIDE_A, site=self.site1, interface=self.interface5, port_speed=1000000 + ) + data = { - 'circuit': self.circuit1.pk, + 'circuit': self.circuit3.pk, 'term_side': TERM_SIDE_Z, 'site': self.site2.pk, + 'interface': self.interface6.pk, 'port_speed': 1000000, } - url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': self.circuittermination1.pk}) + url = reverse('circuits-api:circuittermination-detail', kwargs={'pk': circuittermination5.pk}) response = self.client.put(url, data, format='json', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) - self.assertEqual(CircuitTermination.objects.count(), 3) + self.assertEqual(CircuitTermination.objects.count(), 5) circuittermination1 = CircuitTermination.objects.get(pk=response.data['id']) - self.assertEqual(circuittermination1.circuit_id, data['circuit']) self.assertEqual(circuittermination1.term_side, data['term_side']) self.assertEqual(circuittermination1.site_id, data['site']) + self.assertEqual(circuittermination1.interface_id, data['interface']) self.assertEqual(circuittermination1.port_speed, data['port_speed']) def test_delete_circuittermination(self): @@ -407,4 +437,4 @@ class CircuitTerminationTest(APITestCase): response = self.client.delete(url, **self.header) self.assertHttpStatus(response, status.HTTP_204_NO_CONTENT) - self.assertEqual(CircuitTermination.objects.count(), 2) + self.assertEqual(CircuitTermination.objects.count(), 3) From 2d92f9a7996635c425ecd4f5c0a406b7ff3760b1 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Mon, 5 Nov 2018 16:10:33 -0500 Subject: [PATCH 15/23] Fixed changelog for #2528 --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e24dec5b2..54d97ae24 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ v2.4.7 (FUTURE) * [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled -* [#2528](https://github.com/digitalocean/netbox/issues/2528) - +* [#2528](https://github.com/digitalocean/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API --- From 063c15fc3bb77e870e3270fb644aa887a1661192 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 6 Nov 2018 00:51:55 -0500 Subject: [PATCH 16/23] fixed #2549 - incorrect naming of peer-device and peer-interface --- netbox/dcim/api/views.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index ceec6747d..a7f018b00 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -412,13 +412,13 @@ class ConnectedDeviceViewSet(ViewSet): interface. This is useful in a situation where a device boots with no configuration, but can detect its neighbors via a protocol such as LLDP. Two query parameters must be included in the request: - * `peer-device`: The name of the peer device - * `peer-interface`: The name of the peer interface + * `peer_device`: The name of the peer device + * `peer_interface`: The name of the peer interface """ permission_classes = [IsAuthenticatedOrLoginNotRequired] - _device_param = Parameter('peer-device', 'query', + _device_param = Parameter('peer_device', 'query', description='The name of the peer device', required=True, type=openapi.TYPE_STRING) - _interface_param = Parameter('peer-interface', 'query', + _interface_param = Parameter('peer_interface', 'query', description='The name of the peer interface', required=True, type=openapi.TYPE_STRING) def get_view_name(self): @@ -431,7 +431,7 @@ class ConnectedDeviceViewSet(ViewSet): peer_device_name = request.query_params.get(self._device_param.name) peer_interface_name = request.query_params.get(self._interface_param.name) if not peer_device_name or not peer_interface_name: - raise MissingFilterException(detail='Request must include "peer-device" and "peer-interface" filters.') + raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') # Determine local interface from peer interface's connection peer_interface = get_object_or_404(Interface, device__name=peer_device_name, name=peer_interface_name) From 065522171422b5a53102b71ff578298f7a20c4d7 Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 6 Nov 2018 00:54:57 -0500 Subject: [PATCH 17/23] fixed test for #2549 --- netbox/dcim/tests/test_api.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/tests/test_api.py b/netbox/dcim/tests/test_api.py index 04952a4d4..4c60e79d7 100644 --- a/netbox/dcim/tests/test_api.py +++ b/netbox/dcim/tests/test_api.py @@ -3053,7 +3053,7 @@ class ConnectedDeviceTest(APITestCase): def test_get_connected_device(self): url = reverse('dcim-api:connected-device-list') - response = self.client.get(url + '?peer-device=TestDevice2&peer-interface=eth0', **self.header) + response = self.client.get(url + '?peer_device=TestDevice2&peer_interface=eth0', **self.header) self.assertHttpStatus(response, status.HTTP_200_OK) self.assertEqual(response.data['name'], self.device1.name) From de69e224c35434c3fc1c0d9e9dbddaa06b45d8fd Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 6 Nov 2018 00:57:09 -0500 Subject: [PATCH 18/23] changelog for #2549 --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54d97ae24..f258406f6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,7 @@ v2.4.7 (FUTURE) * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled * [#2528](https://github.com/digitalocean/netbox/issues/2528) - Enable creating circuit terminations with interface assignment via API +* [#2549](https://github.com/digitalocean/netbox/issues/2549) - Changed naming of `peer_device` and `peer_interface` on API /dcim/connected-device/ endpoint to use underscores --- From a5b8ba43f8f93f5bab68e1635ab034f5a127995f Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Nov 2018 09:24:05 -0500 Subject: [PATCH 19/23] Closes #2512: Add device field to inventory item filter form --- CHANGELOG.md | 6 +++++- netbox/dcim/filters.py | 9 +++++++++ netbox/dcim/forms.py | 1 + 3 files changed, 15 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f258406f6..877a45a1e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,12 @@ v2.4.7 (FUTURE) +## Enhancements + +* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID +* [#2512](https://github.com/digitalocean/netbox/issues/2512) - Add device field to inventory item filter form + ## Bug Fixes -* [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID * [#2502](https://github.com/digitalocean/netbox/issues/2502) - Allow duplicate VIPs inside a uniqueness-enforced VRF * [#2514](https://github.com/digitalocean/netbox/issues/2514) - Prevent new connections to already connected interfaces * [#2515](https://github.com/digitalocean/netbox/issues/2515) - Only use django-rq admin tmeplate if webhooks are enabled diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 424e99b59..1870df762 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -707,6 +707,15 @@ class InventoryItemFilter(DeviceComponentFilterSet): method='search', label='Search', ) + device_id = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + label='Device (ID)', + ) + device = django_filters.ModelChoiceFilter( + queryset=Device.objects.all(), + to_field_name='name', + label='Device (name)', + ) parent_id = django_filters.ModelMultipleChoiceFilter( queryset=InventoryItem.objects.all(), label='Parent inventory item (ID)', diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index 6b6ea1187..b24c979c5 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -2201,6 +2201,7 @@ class InventoryItemBulkEditForm(BootstrapMixin, BulkEditForm): class InventoryItemFilterForm(BootstrapMixin, forms.Form): model = InventoryItem q = forms.CharField(required=False, label='Search') + device = forms.CharField(required=False, label='Device name') manufacturer = FilterChoiceField( queryset=Manufacturer.objects.annotate(filter_count=Count('inventory_items')), to_field_name='slug', From 3bd5b7f72409e4f8d3103f32f4be24283186802e Mon Sep 17 00:00:00 2001 From: Ben Bleything Date: Tue, 6 Nov 2018 06:26:05 -0800 Subject: [PATCH 20/23] fix prefix length for 172.16.0.0/12 (#2548) --- docs/core-functionality/ipam.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/core-functionality/ipam.md b/docs/core-functionality/ipam.md index 27bec8e8e..05b613da2 100644 --- a/docs/core-functionality/ipam.md +++ b/docs/core-functionality/ipam.md @@ -4,7 +4,7 @@ The first step to documenting your IP space is to define its scope by creating a * 10.0.0.0/8 (RFC 1918) * 100.64.0.0/10 (RFC 6598) -* 172.16.0.0/20 (RFC 1918) +* 172.16.0.0/12 (RFC 1918) * 192.168.0.0/16 (RFC 1918) * One or more /48s within fd00::/8 (IPv6 unique local addressing) From df14c7204d51916b12cff199df0f66ec898285ef Mon Sep 17 00:00:00 2001 From: John Anderson Date: Tue, 6 Nov 2018 10:08:00 -0500 Subject: [PATCH 21/23] add temporary support for hyphenated query params for #2549 --- netbox/dcim/api/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/api/views.py b/netbox/dcim/api/views.py index a7f018b00..2159661ef 100644 --- a/netbox/dcim/api/views.py +++ b/netbox/dcim/api/views.py @@ -429,7 +429,13 @@ class ConnectedDeviceViewSet(ViewSet): def list(self, request): peer_device_name = request.query_params.get(self._device_param.name) + if not peer_device_name: + # TODO: remove this after 2.4 as the switch to using underscores is a breaking change + peer_device_name = request.query_params.get('peer-device') peer_interface_name = request.query_params.get(self._interface_param.name) + if not peer_interface_name: + # TODO: remove this after 2.4 as the switch to using underscores is a breaking change + peer_interface_name = request.query_params.get('peer-interface') if not peer_device_name or not peer_interface_name: raise MissingFilterException(detail='Request must include "peer_device" and "peer_interface" filters.') From 54af8aa5f2f8b835dee16065b04bf17314fba634 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Nov 2018 10:31:56 -0500 Subject: [PATCH 22/23] Closes #2388: Enable filtering of devices/VMs by region --- CHANGELOG.md | 1 + netbox/dcim/filters.py | 21 +++++++++++++++++++++ netbox/dcim/forms.py | 5 +++++ netbox/virtualization/filters.py | 23 ++++++++++++++++++++++- netbox/virtualization/forms.py | 9 +++++++-- 5 files changed, 56 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 877a45a1e..7647a4152 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,7 @@ v2.4.7 (FUTURE) ## Enhancements +* [#2388](https://github.com/digitalocean/netbox/issues/2388) - Enable filtering of devices/VMs by region * [#2427](https://github.com/digitalocean/netbox/issues/2427) - Allow filtering of interfaces by assigned VLAN or VLAN ID * [#2512](https://github.com/digitalocean/netbox/issues/2512) - Add device field to inventory item filter form diff --git a/netbox/dcim/filters.py b/netbox/dcim/filters.py index 1870df762..689e88a5d 100644 --- a/netbox/dcim/filters.py +++ b/netbox/dcim/filters.py @@ -2,6 +2,7 @@ from __future__ import unicode_literals import django_filters from django.contrib.auth.models import User +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError @@ -456,6 +457,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): ) name = NullableCharFieldFilter() asset_tag = NullableCharFieldFilter() + region_id = django_filters.NumberFilter( + method='filter_region', + name='pk', + label='Region (ID)', + ) + region = django_filters.CharFilter( + method='filter_region', + name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( queryset=Site.objects.all(), label='Site (ID)', @@ -538,6 +549,16 @@ class DeviceFilter(CustomFieldFilterSet, django_filters.FilterSet): Q(comments__icontains=value) ).distinct() + def filter_region(self, queryset, name, value): + try: + region = Region.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + return queryset.filter( + Q(site__region=region) | + Q(site__region__in=region.get_descendants()) + ) + def _mac_address(self, queryset, name, value): value = value.strip() if not value: diff --git a/netbox/dcim/forms.py b/netbox/dcim/forms.py index b24c979c5..46e039211 100644 --- a/netbox/dcim/forms.py +++ b/netbox/dcim/forms.py @@ -1108,6 +1108,11 @@ class DeviceBulkEditForm(BootstrapMixin, AddRemoveTagsForm, CustomFieldBulkEditF class DeviceFilterForm(BootstrapMixin, CustomFieldFilterForm): model = Device q = forms.CharField(required=False, label='Search') + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('devices')), to_field_name='slug', diff --git a/netbox/virtualization/filters.py b/netbox/virtualization/filters.py index 6af4e4a22..99df19aee 100644 --- a/netbox/virtualization/filters.py +++ b/netbox/virtualization/filters.py @@ -1,11 +1,12 @@ from __future__ import unicode_literals import django_filters +from django.core.exceptions import ObjectDoesNotExist from django.db.models import Q from netaddr import EUI from netaddr.core import AddrFormatError -from dcim.models import DeviceRole, Interface, Platform, Site +from dcim.models import DeviceRole, Interface, Platform, Region, Site from extras.filters import CustomFieldFilterSet from tenancy.models import Tenant from utilities.filters import NumericInFilter @@ -116,6 +117,16 @@ class VirtualMachineFilter(CustomFieldFilterSet): queryset=Cluster.objects.all(), label='Cluster (ID)', ) + region_id = django_filters.NumberFilter( + method='filter_region', + name='pk', + label='Region (ID)', + ) + region = django_filters.CharFilter( + method='filter_region', + name='slug', + label='Region (slug)', + ) site_id = django_filters.ModelMultipleChoiceFilter( name='cluster__site', queryset=Site.objects.all(), @@ -173,6 +184,16 @@ class VirtualMachineFilter(CustomFieldFilterSet): Q(comments__icontains=value) ) + def filter_region(self, queryset, name, value): + try: + region = Region.objects.get(**{name: value}) + except ObjectDoesNotExist: + return queryset.none() + return queryset.filter( + Q(cluster__site__region=region) | + Q(cluster__site__region__in=region.get_descendants()) + ) + class InterfaceFilter(django_filters.FilterSet): virtual_machine_id = django_filters.ModelMultipleChoiceFilter( diff --git a/netbox/virtualization/forms.py b/netbox/virtualization/forms.py index 8f973955c..6d11ed78a 100644 --- a/netbox/virtualization/forms.py +++ b/netbox/virtualization/forms.py @@ -16,8 +16,8 @@ from tenancy.models import Tenant from utilities.forms import ( AnnotatedMultipleChoiceField, APISelect, APISelectMultiple, BootstrapMixin, BulkEditForm, BulkEditNullBooleanSelect, ChainedFieldsMixin, ChainedModelChoiceField, ChainedModelMultipleChoiceField, CommentField, ComponentForm, - ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, JSONField, SlugField, SmallTextarea, - add_blank_choice + ConfirmationForm, CSVChoiceField, ExpandableNameField, FilterChoiceField, FilterTreeNodeMultipleChoiceField, + JSONField, SlugField, SmallTextarea, add_blank_choice, ) from .constants import VM_STATUS_CHOICES from .models import Cluster, ClusterGroup, ClusterType, VirtualMachine @@ -386,6 +386,11 @@ class VirtualMachineFilterForm(BootstrapMixin, CustomFieldFilterForm): queryset=Cluster.objects.annotate(filter_count=Count('virtual_machines')), label='Cluster' ) + region = FilterTreeNodeMultipleChoiceField( + queryset=Region.objects.all(), + to_field_name='slug', + required=False, + ) site = FilterChoiceField( queryset=Site.objects.annotate(filter_count=Count('clusters__virtual_machines')), to_field_name='slug', From 04eebe07a5cccb033b8c4f4b1cf5339c797cc0d2 Mon Sep 17 00:00:00 2001 From: Jeremy Stretch Date: Tue, 6 Nov 2018 10:49:44 -0500 Subject: [PATCH 23/23] Release v2.4.7 --- CHANGELOG.md | 2 +- netbox/netbox/settings.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7647a4152..42bece984 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -v2.4.7 (FUTURE) +v2.4.7 (2018-11-06) ## Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index c96503e4d..ee54ef346 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -22,7 +22,7 @@ if sys.version_info[0] < 3: DeprecationWarning ) -VERSION = '2.4.7-dev' +VERSION = '2.4.7' BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))