From d29d265b0aadda0913c46904d829d1b7ddc8a82e Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 2 Sep 2022 15:44:41 -0400 Subject: [PATCH 01/30] PRVB --- docs/release-notes/version-3.3.md | 4 ++++ netbox/netbox/settings.py | 2 +- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 3482c9061..e886309e1 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -1,5 +1,9 @@ # NetBox v3.3 +## v3.3.3 (FUTURE) + +--- + ## v3.3.2 (2022-09-02) ### Enhancements diff --git a/netbox/netbox/settings.py b/netbox/netbox/settings.py index 8f32de207..f96b6085b 100644 --- a/netbox/netbox/settings.py +++ b/netbox/netbox/settings.py @@ -29,7 +29,7 @@ django.utils.encoding.force_text = force_str # Environment setup # -VERSION = '3.3.2' +VERSION = '3.3.3-dev' # Hostname HOSTNAME = platform.node() From 536bd37d05a8eeba6f17b64a5d0ad2a207063be8 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Sep 2022 16:37:52 -0700 Subject: [PATCH 02/30] #9231 make empty search work --- netbox/netbox/filtersets.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 3a0434592..2dec13d88 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -80,6 +80,10 @@ class BaseFilterSet(django_filters.FilterSet): }, }) + def __init__(self, *args, **kwargs): + self.base_filters = self.get_filters() + super().__init__(*args, **kwargs) + @staticmethod def _get_filter_lookup_dict(existing_filter): # Choose the lookup expression map based on the filter type From 2fe620df7019bf6eb6e5397b85f2e595e2b86768 Mon Sep 17 00:00:00 2001 From: Arthur Date: Tue, 6 Sep 2022 18:04:29 -0700 Subject: [PATCH 03/30] #9231 call class method --- netbox/netbox/filtersets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2dec13d88..2e535266d 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -81,7 +81,7 @@ class BaseFilterSet(django_filters.FilterSet): }) def __init__(self, *args, **kwargs): - self.base_filters = self.get_filters() + self.base_filters = self.__class__.get_filters() super().__init__(*args, **kwargs) @staticmethod From 48a907ae4580f77eea74180c826952c8c3d3d5e7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 08:09:28 -0700 Subject: [PATCH 04/30] #9231 add comment --- netbox/netbox/filtersets.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 2e535266d..b6776e3c1 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -81,6 +81,9 @@ class BaseFilterSet(django_filters.FilterSet): }) def __init__(self, *args, **kwargs): + # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready + # however FilterSet Factory is setup before this which creates the + # initial filters. This recreates the filters so Empty is picked up correctly. self.base_filters = self.__class__.get_filters() super().__init__(*args, **kwargs) From ac8f0a7ef2e094c06ac67ddd1aa33e46689148b7 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 11:23:13 -0400 Subject: [PATCH 05/30] Add installation video to docs --- docs/installation/index.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/installation/index.md b/docs/installation/index.md index 905add7ab..8b588fccd 100644 --- a/docs/installation/index.md +++ b/docs/installation/index.md @@ -2,6 +2,8 @@ The installation instructions provided here have been tested to work on Ubuntu 20.04 and CentOS 8.3. The particular commands needed to install dependencies on other distributions may vary significantly. Unfortunately, this is outside the control of the NetBox maintainers. Please consult your distribution's documentation for assistance with any errors. + + The following sections detail how to set up a new instance of NetBox: 1. [PostgreSQL database](1-postgresql.md) From 7826cfb01faef3574cd071a982c3d1bebb9381fa Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 08:45:56 -0700 Subject: [PATCH 06/30] #10270 - fix custom field validation for ipam services --- netbox/ipam/forms/models.py | 1 + 1 file changed, 1 insertion(+) diff --git a/netbox/ipam/forms/models.py b/netbox/ipam/forms/models.py index 34bf739f4..724812585 100644 --- a/netbox/ipam/forms/models.py +++ b/netbox/ipam/forms/models.py @@ -854,6 +854,7 @@ class ServiceCreateForm(ServiceForm): del self.fields[field].widget.attrs['required'] def clean(self): + super().clean() if self.cleaned_data['service_template']: # Create a new Service from the specified template service_template = self.cleaned_data['service_template'] From def853e8c444f646b550be8f6e7e8a22d18a27b2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 10:40:24 -0700 Subject: [PATCH 07/30] #10278 add get_extra_addanother_params --- netbox/extras/views.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/extras/views.py b/netbox/extras/views.py index 5b589c181..30f48f817 100644 --- a/netbox/extras/views.py +++ b/netbox/extras/views.py @@ -441,6 +441,12 @@ class ImageAttachmentEditView(generic.ObjectEditView): def get_return_url(self, request, obj=None): return obj.parent.get_absolute_url() if obj else super().get_return_url(request) + def get_extra_addanother_params(self, request): + return { + 'content_type': request.GET.get('content_type'), + 'object_id': request.GET.get('object_id'), + } + class ImageAttachmentDeleteView(generic.ObjectDeleteView): queryset = ImageAttachment.objects.all() From 1cbb2320c1fba4998939b63cea210b129ed3e6be Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 13:59:00 -0400 Subject: [PATCH 08/30] Changelog for #9231, #10270, #10278 --- docs/release-notes/version-3.3.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index e886309e1..fe13eaf9f 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,12 @@ ## v3.3.3 (FUTURE) +### Bug Fixes + +* [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services +* [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments + --- ## v3.3.2 (2022-09-02) From b7028228578066b81e5f26d83cbba6c303401efe Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:09:17 -0400 Subject: [PATCH 09/30] Closes #10268: Omit trailing ".0" in device positions within UI --- docs/release-notes/version-3.3.md | 4 ++++ netbox/dcim/tables/devices.py | 11 +++++++---- netbox/templates/dcim/device.html | 2 +- netbox/templates/dcim/virtualchassis_edit.html | 2 +- 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fe13eaf9f..049e8acc5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -2,6 +2,10 @@ ## v3.3.3 (FUTURE) +### Enhancements + +* [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI + ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters diff --git a/netbox/dcim/tables/devices.py b/netbox/dcim/tables/devices.py index 036f83306..c42731b90 100644 --- a/netbox/dcim/tables/devices.py +++ b/netbox/dcim/tables/devices.py @@ -152,6 +152,9 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): rack = tables.Column( linkify=True ) + position = columns.TemplateColumn( + template_code='{{ value|floatformat }}' + ) device_role = columns.ColoredLabelColumn( verbose_name='Role' ) @@ -199,10 +202,10 @@ class DeviceTable(TenancyColumnsMixin, NetBoxTable): class Meta(NetBoxTable.Meta): model = Device fields = ( - 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', 'platform', 'serial', - 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', 'primary_ip4', - 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', 'contacts', 'tags', - 'created', 'last_updated', + 'pk', 'id', 'name', 'status', 'tenant', 'tenant_group', 'device_role', 'manufacturer', 'device_type', + 'platform', 'serial', 'asset_tag', 'site', 'location', 'rack', 'position', 'face', 'primary_ip', 'airflow', + 'primary_ip4', 'primary_ip6', 'cluster', 'virtual_chassis', 'vc_position', 'vc_priority', 'comments', + 'contacts', 'tags', 'created', 'last_updated', ) default_columns = ( 'pk', 'name', 'status', 'tenant', 'site', 'location', 'rack', 'device_role', 'manufacturer', 'device_type', diff --git a/netbox/templates/dcim/device.html b/netbox/templates/dcim/device.html index 8a70db621..6cc859749 100644 --- a/netbox/templates/dcim/device.html +++ b/netbox/templates/dcim/device.html @@ -70,7 +70,7 @@ {% endif %} {% endwith %} {% elif object.rack and object.position %} - U{{ object.position }} / {{ object.get_face_display }} + U{{ object.position|floatformat }} / {{ object.get_face_display }} {% elif object.rack and object.device_type.u_height %} Not racked {% else %} diff --git a/netbox/templates/dcim/virtualchassis_edit.html b/netbox/templates/dcim/virtualchassis_edit.html index 275391c61..87917f2a2 100644 --- a/netbox/templates/dcim/virtualchassis_edit.html +++ b/netbox/templates/dcim/virtualchassis_edit.html @@ -55,7 +55,7 @@ {{ device.pk }} {% if device.rack %} - {{ device.rack }} / {{ device.position }} + {{ device.rack }} / {{ device.position|floatformat }} {% else %} {{ ''|placeholder }} {% endif %} From 51d066a1bc05ef10aada58635c4142b0e42149f6 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:30:51 -0400 Subject: [PATCH 10/30] Fixes #10259: Fix NoReverseMatch exception when listing available prefixes with "flat" column displayed --- docs/release-notes/version-3.3.md | 1 + netbox/ipam/tables/ip.py | 20 ++++++++++++++------ 2 files changed, 15 insertions(+), 6 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 049e8acc5..beceae802 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,6 +9,7 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments diff --git a/netbox/ipam/tables/ip.py b/netbox/ipam/tables/ip.py index f3c45ec1d..a820385ed 100644 --- a/netbox/ipam/tables/ip.py +++ b/netbox/ipam/tables/ip.py @@ -21,6 +21,14 @@ __all__ = ( AVAILABLE_LABEL = mark_safe('Available') PREFIX_LINK = """ +{% if record.pk %} + {{ record.prefix }} +{% else %} + {{ record.prefix }} +{% endif %} +""" + +PREFIX_LINK_WITH_DEPTH = """ {% load helpers %} {% if record.depth %}
@@ -29,8 +37,7 @@ PREFIX_LINK = """ {% endfor %}
{% endif %} -{{ record.prefix }} -""" +""" + PREFIX_LINK IPADDRESS_LINK = """ {% if record.pk %} @@ -216,14 +223,15 @@ class PrefixUtilizationColumn(columns.UtilizationColumn): class PrefixTable(TenancyColumnsMixin, NetBoxTable): prefix = columns.TemplateColumn( - template_code=PREFIX_LINK, + template_code=PREFIX_LINK_WITH_DEPTH, export_raw=True, attrs={'td': {'class': 'text-nowrap'}} ) - prefix_flat = tables.Column( + prefix_flat = columns.TemplateColumn( accessor=Accessor('prefix'), - linkify=True, - verbose_name='Prefix (Flat)', + template_code=PREFIX_LINK, + export_raw=True, + verbose_name='Prefix (Flat)' ) depth = tables.Column( accessor=Accessor('_depth'), From 211c7641c182b53990be7a4186d04a342e53de57 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Wed, 7 Sep 2022 14:41:48 -0400 Subject: [PATCH 11/30] Fixes #10250: Fix exception when CableTermination validation fails during bulk import of cables --- netbox/dcim/models/cables.py | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/models/cables.py b/netbox/dcim/models/cables.py index ab1fe88e4..e05eb6d51 100644 --- a/netbox/dcim/models/cables.py +++ b/netbox/dcim/models/cables.py @@ -281,15 +281,11 @@ class CableTermination(models.Model): # Validate interface type (if applicable) if self.termination_type.model == 'interface' and self.termination.type in NONCONNECTABLE_IFACE_TYPES: - raise ValidationError({ - 'termination': f'Cables cannot be terminated to {self.termination.get_type_display()} interfaces' - }) + raise ValidationError(f"Cables cannot be terminated to {self.termination.get_type_display()} interfaces") # A CircuitTermination attached to a ProviderNetwork cannot have a Cable if self.termination_type.model == 'circuittermination' and self.termination.provider_network is not None: - raise ValidationError({ - 'termination': "Circuit terminations attached to a provider network may not be cabled." - }) + raise ValidationError("Circuit terminations attached to a provider network may not be cabled.") def save(self, *args, **kwargs): From b4877e7fac49282a766ebcdd2f886f71e8d61fa5 Mon Sep 17 00:00:00 2001 From: Arthur Date: Wed, 7 Sep 2022 15:45:01 -0700 Subject: [PATCH 12/30] #8580 add interface filters for connected --- netbox/dcim/filtersets.py | 9 +++++++++ netbox/dcim/forms/filtersets.py | 19 +++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5d92af878..1a9887c31 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1304,6 +1304,9 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class Meta: model = Interface @@ -1359,6 +1362,12 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 173ea5d1e..85fe909c5 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1009,6 +1009,7 @@ class InterfaceFilterForm(DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'is_occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1087,6 +1088,24 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) class FrontPortFilterForm(DeviceComponentFilterForm): From 3dbc7bdd2c78583902733e6a9a7d59d7ce07d649 Mon Sep 17 00:00:00 2001 From: kkthxbye-code Date: Thu, 8 Sep 2022 11:38:39 +0200 Subject: [PATCH 13/30] Add wwn CharField to InterfaceForm --- netbox/dcim/forms/models.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/netbox/dcim/forms/models.py b/netbox/dcim/forms/models.py index d1d5b1683..a21265db4 100644 --- a/netbox/dcim/forms/models.py +++ b/netbox/dcim/forms/models.py @@ -1331,6 +1331,12 @@ class InterfaceForm(InterfaceCommonForm, NetBoxModelForm): label='VRF' ) + wwn = forms.CharField( + empty_value=None, + required=False, + label='WWN' + ) + fieldsets = ( ('Interface', ('device', 'module', 'name', 'type', 'speed', 'duplex', 'label', 'description', 'tags')), ('Addressing', ('vrf', 'mac_address', 'wwn')), From 6b70436e2baeac91243acb81162a935e6e7319de Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 09:18:45 -0400 Subject: [PATCH 14/30] Add Repography stats --- README.md | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 93e125079..654b290ee 100644 --- a/README.md +++ b/README.md @@ -2,8 +2,6 @@ NetBox logo -![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) - NetBox is the leading solution for modeling and documenting modern networks. By combining the traditional disciplines of IP address management (IPAM) and datacenter infrastructure management (DCIM) with powerful APIs and extensions, @@ -11,6 +9,16 @@ NetBox provides the ideal "source of truth" to power network automation. Available as open source software under the Apache 2.0 license, NetBox is employed by thousands of organizations around the world. +![Master branch build status](https://github.com/netbox-community/netbox/workflows/CI/badge.svg?branch=master) + +[![Timeline graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_timeline.svg)](https://github.com/netbox-community/netbox/commits) +[![Issue status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_issues.svg)](https://github.com/netbox-community/netbox/issues) +[![Pull request status graph](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_prs.svg)](https://github.com/netbox-community/netbox/pulls) +[![Top contributors](https://images.repography.com/29023055/netbox-community/netbox/recent-activity/31db894eee74b8a5475e3af307a81b6c_users.svg)](https://github.com/netbox-community/netbox/graphs/contributors) +
Stats via [Repography](https://repography.com) + +## About NetBox + ![Screenshot of Netbox UI](docs/media/screenshots/netbox-ui.png "NetBox UI") Myriad infrastructure components can be modeled in NetBox, including: @@ -57,7 +65,7 @@ complete list of requirements, see `requirements.txt`. The code is available [on GitHub](https://github.com/netbox-community/netbox).
-

Thank you to our sponsors!

+

Thank you to our sponsors!

[![DigitalOcean](https://raw.githubusercontent.com/wiki/netbox-community/netbox/images/sponsors/digitalocean.png)](https://try.digitalocean.com/developer-cloud)            From c6644ec1ae688d980f4d6fdb954cadf09fd07369 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 10:00:53 -0400 Subject: [PATCH 15/30] Annotate upgrade paths in upgrade documentation --- docs/installation/3-netbox.md | 2 +- docs/installation/upgrading.md | 21 +++++++++++++++------ docs/media/installation/upgrade_paths.png | Bin 0 -> 9038 bytes 3 files changed, 16 insertions(+), 7 deletions(-) create mode 100644 docs/media/installation/upgrade_paths.png diff --git a/docs/installation/3-netbox.md b/docs/installation/3-netbox.md index eeb5e6f20..f42e28deb 100644 --- a/docs/installation/3-netbox.md +++ b/docs/installation/3-netbox.md @@ -7,7 +7,7 @@ This section of the documentation discusses installing and configuring the NetBo Begin by installing all system packages required by NetBox and its dependencies. !!! warning "Python 3.8 or later required" - NetBox v3.2 requires Python 3.8, 3.9, or 3.10. + NetBox requires Python 3.8, 3.9, or 3.10. === "Ubuntu" diff --git a/docs/installation/upgrading.md b/docs/installation/upgrading.md index deeec883a..802c13e49 100644 --- a/docs/installation/upgrading.md +++ b/docs/installation/upgrading.md @@ -1,10 +1,19 @@ # Upgrading to a New NetBox Release -## Review the Release Notes +Upgrading NetBox to a new version is pretty simple, however users are cautioned to always review the release notes and save a backup of their current deployment prior to beginning an upgrade. + +NetBox can generally be upgraded directly to any newer release with no interim steps, with the one exception being incrementing major versions. This can be done only from the most recent _minor_ release of the major version. For example, NetBox v2.11.8 can be upgraded to version 3.3.2 following the steps below. However, a deployment of NetBox v2.10.10 or earlier must first be upgraded to any v2.11 release, and then to any v3.x release. (This is to accommodate the consolidation of database schema migrations effected by a major version change). + +[![Upgrade paths](../media/installation/upgrade_paths.png)](../media/installation/upgrade_paths.png) + +!!! warning "Perform a Backup" + Always be sure to save a backup of your current NetBox deployment prior to starting the upgrade process. + +## 1. Review the Release Notes Prior to upgrading your NetBox instance, be sure to carefully review all [release notes](../release-notes/index.md) 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 release in which the change went into effect. -## Update Dependencies to Required Versions +## 2. Update Dependencies to Required Versions NetBox v3.0 and later require the following: @@ -14,7 +23,7 @@ NetBox v3.0 and later require the following: | PostgreSQL | 10 | | Redis | 4.0 | -## Install the Latest Release +## 3. Install the Latest Release 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. @@ -87,7 +96,7 @@ sudo git pull origin master sudo git checkout v2.11.11 -## Run the Upgrade Script +## 4. Run the Upgrade Script Once the new code is in place, verify that any optional Python packages required by your deployment (e.g. `napalm` or `django-auth-ldap`) are listed in `local_requirements.txt`. Then, run the upgrade script: @@ -118,7 +127,7 @@ This script performs the following actions: been made to your local codebase and should be investigated. Never attempt to create new migrations unless you are intentionally modifying the database schema. -## Restart the NetBox Services +## 5. Restart the NetBox Services !!! warning If you are upgrading from an installation that does not use a Python virtual environment (any release prior to v2.7.9), you'll need to update the systemd service files to reference the new Python and gunicorn executables before restarting the services. These are located in `/opt/netbox/venv/bin/`. See the example service files in `/opt/netbox/contrib/` for reference. @@ -129,7 +138,7 @@ Finally, restart the gunicorn and RQ services: sudo systemctl restart netbox netbox-rq ``` -## Verify Housekeeping Scheduling +## 6. Verify Housekeeping Scheduling If upgrading from a release prior to NetBox v3.0, check that a cron task (or similar scheduled process) has been configured to run NetBox's nightly housekeeping command. A shell script which invokes this command is included at `contrib/netbox-housekeeping.sh`. It can be linked from your system's daily cron task directory, or included within the crontab directly. (If NetBox has been installed in a nonstandard path, be sure to update the system paths within this script first.) diff --git a/docs/media/installation/upgrade_paths.png b/docs/media/installation/upgrade_paths.png new file mode 100644 index 0000000000000000000000000000000000000000..494744b5826c9a13aab8ee14bc0620d26a84de7d GIT binary patch literal 9038 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 literal 0 HcmV?d00001 From d51e833bf378912c67f60d6eab6f6ba9565a6f09 Mon Sep 17 00:00:00 2001 From: Arthur Date: Thu, 8 Sep 2022 13:11:17 -0700 Subject: [PATCH 16/30] #8580 changes from code review --- netbox/dcim/filtersets.py | 6 ++--- netbox/dcim/forms/filtersets.py | 41 ++++++++++++++++++--------------- 2 files changed, 25 insertions(+), 22 deletions(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 1a9887c31..4ccf0dda5 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,6 +1144,9 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) + is_occupied = django_filters.BooleanFilter( + method='filter_is_occupied' + ) class PathEndpointFilterSet(django_filters.FilterSet): @@ -1304,9 +1307,6 @@ class InterfaceFilterSet( to_field_name='rd', label='VRF (RD)', ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' - ) class Meta: model = Interface diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 85fe909c5..fe92350f9 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1000,7 +1000,28 @@ class PowerOutletFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class InterfaceFilterForm(DeviceComponentFilterForm): +class CabledFilterForm(forms.Form): + cabled = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + is_occupied = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1088,24 +1109,6 @@ class InterfaceFilterForm(DeviceComponentFilterForm): label='VRF' ) tag = TagFilterField(model) - cabled = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) - is_occupied = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) class FrontPortFilterForm(DeviceComponentFilterForm): From 385a0f979ec460023d3d7f8e9003ddc373fb5ac3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Thu, 8 Sep 2022 16:50:16 -0400 Subject: [PATCH 17/30] Changelog for #10250, #10294 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index beceae802..fcd5bc0f5 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -9,9 +9,11 @@ ### Bug Fixes * [#9231](https://github.com/netbox-community/netbox/issues/9231) - Fix `empty` lookup expression for string filters +* [#10250](https://github.com/netbox-community/netbox/issues/10250) - Fix exception when CableTermination validation fails during bulk import of cables * [#10259](https://github.com/netbox-community/netbox/issues/10259) - Fix `NoReverseMatch` exception when listing available prefixes with "flat" column displayed * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments +* [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field --- From 59a2a43473b4e0ebc0670b5901328ecc64bd8be2 Mon Sep 17 00:00:00 2001 From: Arthur Date: Fri, 9 Sep 2022 07:40:01 -0700 Subject: [PATCH 18/30] #10307 fix choices for poe type --- netbox/dcim/choices.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 019ae09a4..7d35a40f9 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1096,7 +1096,7 @@ class InterfacePoETypeChoices(ChoiceSet): (PASSIVE_24V_2PAIR, 'Passive 24V (2-pair)'), (PASSIVE_24V_4PAIR, 'Passive 24V (4-pair)'), (PASSIVE_48V_2PAIR, 'Passive 48V (2-pair)'), - (PASSIVE_48V_2PAIR, 'Passive 48V (4-pair)'), + (PASSIVE_48V_4PAIR, 'Passive 48V (4-pair)'), ) ), ) From cd1ad452da5fdffda3d2dd50d44c44b2d5d79b2d Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 16:44:58 -0400 Subject: [PATCH 19/30] Move clone() to CloningMixin --- netbox/netbox/models/__init__.py | 24 ++---------------------- netbox/netbox/models/features.py | 30 +++++++++++++++++++++++++++++- 2 files changed, 31 insertions(+), 23 deletions(-) diff --git a/netbox/netbox/models/__init__.py b/netbox/netbox/models/__init__.py index 4c65094ca..aefb733b4 100644 --- a/netbox/netbox/models/__init__.py +++ b/netbox/netbox/models/__init__.py @@ -2,7 +2,6 @@ from django.core.validators import ValidationError from django.db import models from mptt.models import MPTTModel, TreeForeignKey -from extras.utils import is_taggable from utilities.mptt import TreeManager from utilities.querysets import RestrictedQuerySet from netbox.models.features import * @@ -32,7 +31,7 @@ class NetBoxFeatureSet( def get_prerequisite_models(cls): """ Return a list of model types that are required to create this model or empty list if none. This is used for - showing prequisite warnings in the UI on the list and detail views. + showing prerequisite warnings in the UI on the list and detail views. """ return [] @@ -52,7 +51,7 @@ class ChangeLoggedModel(ChangeLoggingMixin, CustomValidationMixin, models.Model) abstract = True -class NetBoxModel(NetBoxFeatureSet, models.Model): +class NetBoxModel(CloningMixin, NetBoxFeatureSet, models.Model): """ Primary models represent real objects within the infrastructure being modeled. """ @@ -61,25 +60,6 @@ class NetBoxModel(NetBoxFeatureSet, models.Model): class Meta: abstract = True - def clone(self): - """ - Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- - populating an object creation form in the UI. - """ - attrs = {} - - for field_name in getattr(self, 'clone_fields', []): - field = self._meta.get_field(field_name) - field_value = field.value_from_object(self) - if field_value not in (None, ''): - attrs[field_name] = field_value - - # Include tags (if applicable) - if is_taggable(self): - attrs['tags'] = [tag.pk for tag in self.tags.all()] - - return attrs - class NestedGroupModel(NetBoxFeatureSet, MPTTModel): """ diff --git a/netbox/netbox/models/features.py b/netbox/netbox/models/features.py index 6b2ee1f94..7f30248b4 100644 --- a/netbox/netbox/models/features.py +++ b/netbox/netbox/models/features.py @@ -10,12 +10,13 @@ from django.db import models from taggit.managers import TaggableManager from extras.choices import CustomFieldVisibilityChoices, ObjectChangeActionChoices -from extras.utils import register_features +from extras.utils import is_taggable, register_features from netbox.signals import post_clean from utilities.utils import serialize_object __all__ = ( 'ChangeLoggingMixin', + 'CloningMixin', 'CustomFieldsMixin', 'CustomLinksMixin', 'CustomValidationMixin', @@ -82,6 +83,33 @@ class ChangeLoggingMixin(models.Model): return objectchange +class CloningMixin(models.Model): + """ + Provides the clone() method used to prepare a copy of existing objects. + """ + class Meta: + abstract = True + + def clone(self): + """ + Return a dictionary of attributes suitable for creating a copy of the current instance. This is used for pre- + populating an object creation form in the UI. + """ + attrs = {} + + for field_name in getattr(self, 'clone_fields', []): + field = self._meta.get_field(field_name) + field_value = field.value_from_object(self) + if field_value not in (None, ''): + attrs[field_name] = field_value + + # Include tags (if applicable) + if is_taggable(self): + attrs['tags'] = [tag.pk for tag in self.tags.all()] + + return attrs + + class CustomFieldsMixin(models.Model): """ Enables support for custom fields. From 2b2a41edd29042e7b94484bd3476eccb73dcb918 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 16:51:18 -0400 Subject: [PATCH 20/30] Enable cloning for custom fields & custom links --- netbox/extras/models/customfields.py | 10 ++++++++-- netbox/extras/models/models.py | 8 ++++++-- 2 files changed, 14 insertions(+), 4 deletions(-) diff --git a/netbox/extras/models/customfields.py b/netbox/extras/models/customfields.py index 426565231..43c4f9671 100644 --- a/netbox/extras/models/customfields.py +++ b/netbox/extras/models/customfields.py @@ -14,7 +14,7 @@ from django.utils.safestring import mark_safe from extras.choices import * from extras.utils import FeatureQuery from netbox.models import ChangeLoggedModel -from netbox.models.features import ExportTemplatesMixin, WebhooksMixin +from netbox.models.features import CloningMixin, ExportTemplatesMixin, WebhooksMixin from utilities import filters from utilities.forms import ( CSVChoiceField, CSVMultipleChoiceField, DatePicker, DynamicModelChoiceField, DynamicModelMultipleChoiceField, @@ -41,7 +41,7 @@ class CustomFieldManager(models.Manager.from_queryset(RestrictedQuerySet)): return self.get_queryset().filter(content_types=content_type) -class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomField(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): content_types = models.ManyToManyField( to=ContentType, related_name='custom_fields', @@ -143,8 +143,14 @@ class CustomField(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): verbose_name='UI visibility', help_text='Specifies the visibility of custom field in the UI' ) + objects = CustomFieldManager() + clone_fields = ( + 'content_types', 'type', 'object_type', 'group_name', 'description', 'required', 'filter_logic', 'default', + 'weight', 'validation_minimum', 'validation_maximum', 'validation_regex', 'choices', 'ui_visibility', + ) + class Meta: ordering = ['group_name', 'weight', 'name'] diff --git a/netbox/extras/models/models.py b/netbox/extras/models/models.py index 4873a1f9e..0df34c146 100644 --- a/netbox/extras/models/models.py +++ b/netbox/extras/models/models.py @@ -21,7 +21,7 @@ from extras.conditions import ConditionSet from extras.utils import FeatureQuery, image_upload from netbox.models import ChangeLoggedModel from netbox.models.features import ( - CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, + CloningMixin, CustomFieldsMixin, CustomLinksMixin, ExportTemplatesMixin, JobResultsMixin, TagsMixin, WebhooksMixin, ) from utilities.querysets import RestrictedQuerySet from utilities.utils import render_jinja2 @@ -187,7 +187,7 @@ class Webhook(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): return render_jinja2(self.payload_url, context) -class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): +class CustomLink(CloningMixin, ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): """ A custom link to an external representation of a NetBox object. The link text and URL fields accept Jinja2 template code to be rendered with an object as context. @@ -230,6 +230,10 @@ class CustomLink(ExportTemplatesMixin, WebhooksMixin, ChangeLoggedModel): help_text="Force link to open in a new window" ) + clone_fields = ( + 'content_type', 'enabled', 'weight', 'group_name', 'button_class', 'new_window', + ) + class Meta: ordering = ['group_name', 'weight', 'name'] From 3aac62caa7ead476410129fb7072de30cfcecf6c Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Fri, 9 Sep 2022 17:08:12 -0400 Subject: [PATCH 21/30] Changelog for #10304, #10307 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index fcd5bc0f5..480595d56 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -14,6 +14,8 @@ * [#10270](https://github.com/netbox-community/netbox/issues/10270) - Fix custom field validation when creating new services * [#10278](https://github.com/netbox-community/netbox/issues/10278) - Fix "create & add another" for image attachments * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field +* [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links +* [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection --- From 721cd578bb02c9a1e6d14042fdb464892530ae80 Mon Sep 17 00:00:00 2001 From: Arthur Hanson Date: Mon, 12 Sep 2022 06:16:17 -0700 Subject: [PATCH 22/30] 10310 pre commit yarn (#10315) * #10310 run yarn pre-commit only if static files changed --- scripts/git-hooks/pre-commit | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/scripts/git-hooks/pre-commit b/scripts/git-hooks/pre-commit index 7a3d680a4..2ccf8df89 100755 --- a/scripts/git-hooks/pre-commit +++ b/scripts/git-hooks/pre-commit @@ -40,10 +40,13 @@ if [ $? != 0 ]; then EXIT=1 fi -echo "Checking UI ESLint, TypeScript, and Prettier compliance..." -yarn --cwd "$PWD/netbox/project-static" validate -if [ $? != 0 ]; then - EXIT=1 +git diff --cached --name-only | if grep --quiet 'netbox/project-static/' +then + echo "Checking UI ESLint, TypeScript, and Prettier compliance..." + yarn --cwd "$PWD/netbox/project-static" validate + if [ $? != 0 ]; then + EXIT=1 + fi fi if [ $EXIT != 0 ]; then From f67cb71dbce226a36974f152bb8f48c158a6f5f3 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 10:36:19 -0400 Subject: [PATCH 23/30] Fixes #10333: Show available values for ui_visibility field of CustomField for CSV import --- netbox/extras/forms/bulk_import.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/netbox/extras/forms/bulk_import.py b/netbox/extras/forms/bulk_import.py index d9148a5c3..e83cac3b9 100644 --- a/netbox/extras/forms/bulk_import.py +++ b/netbox/extras/forms/bulk_import.py @@ -3,7 +3,7 @@ from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.forms import SimpleArrayField from django.utils.safestring import mark_safe -from extras.choices import CustomFieldTypeChoices +from extras.choices import CustomFieldVisibilityChoices, CustomFieldTypeChoices from extras.models import * from extras.utils import FeatureQuery from utilities.forms import CSVChoiceField, CSVContentTypeField, CSVModelForm, CSVMultipleContentTypeField, SlugField @@ -38,6 +38,10 @@ class CustomFieldCSVForm(CSVModelForm): required=False, help_text='Comma-separated list of field choices' ) + ui_visibility = CSVChoiceField( + choices=CustomFieldVisibilityChoices, + help_text='How the custom field is displayed in the user interface' + ) class Meta: model = CustomField From d078befd33f7c0aabe5cc9717db13886cda40985 Mon Sep 17 00:00:00 2001 From: Jonathan Senecal Date: Mon, 12 Sep 2022 11:48:26 -0400 Subject: [PATCH 24/30] Introduce pyproject.toml - Tweaked to not break the existing codebase... too much --- pyproject.toml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 pyproject.toml diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 000000000..6d579b737 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,13 @@ +# See PEP 518 for the spec of this file +# https://www.python.org/dev/peps/pep-0518/ + +[tool.black] +line-length = 120 +target_version = ['py38', 'py39', 'py310'] +skip-string-normalization = true + +[tool.isort] +profile = "black" + +[tool.pylint] +max-line-length = 120 From 1daa2ff98d07020c864c90b5f0e306956da35479 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:22:05 -0700 Subject: [PATCH 25/30] #8580 add tests --- netbox/dcim/tests/test_filtersets.py | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 1aaf861ef..15e109030 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2741,12 +2741,6 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'label': ['A', 'B']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_enabled(self): params = {'enabled': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -2885,6 +2879,18 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'cabled': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + + def is_occupied(self): + params = {'is_occupied': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'is_occupied': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) From d24f10ce6e2878a8963412ec271f0dd449862bf7 Mon Sep 17 00:00:00 2001 From: Arthur Date: Mon, 12 Sep 2022 10:52:40 -0700 Subject: [PATCH 26/30] #8580 add tests --- netbox/circuits/tests/test_filtersets.py | 18 ++++++++++++++++-- netbox/dcim/filtersets.py | 12 ++++++------ netbox/dcim/tests/test_filtersets.py | 6 +++--- 3 files changed, 25 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index abcfa8a00..ada3d9bf1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -344,6 +344,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 4'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 5'), Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 6'), + Circuit(provider=providers[0], type=circuit_types[0], cid='Circuit 7'), ) Circuit.objects.bulk_create(circuits) @@ -357,6 +358,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): CircuitTermination(circuit=circuits[3], provider_network=provider_networks[0], term_side='A'), CircuitTermination(circuit=circuits[4], provider_network=provider_networks[1], term_side='A'), CircuitTermination(circuit=circuits[5], provider_network=provider_networks[2], term_side='A'), + CircuitTermination(circuit=circuits[6], provider_network=provider_networks[0], term_side='A', mark_connected=True), )) CircuitTermination.objects.bulk_create(circuit_terminations) @@ -364,7 +366,7 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_term_side(self): params = {'term_side': 'A'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) def test_port_speed(self): params = {'port_speed': ['1000', '2000']} @@ -397,12 +399,24 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_provider_network(self): provider_networks = ProviderNetwork.objects.all()[:2] params = {'provider_network_id': [provider_networks[0].pk, provider_networks[1].pk]} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + + def test_is_occupied(self): + params = {'is_occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) + params = {'is_occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) + class ProviderNetworkTestCase(TestCase, ChangeLoggedFilterSetTests): queryset = ProviderNetwork.objects.all() diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 4ccf0dda5..3af5883ba 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1148,6 +1148,12 @@ class CabledObjectFilterSet(django_filters.FilterSet): method='filter_is_occupied' ) + def filter_is_occupied(self, queryset, name, value): + if value: + return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) + else: + return queryset.filter(cable__isnull=True, mark_connected=False) + class PathEndpointFilterSet(django_filters.FilterSet): connected = django_filters.BooleanFilter( @@ -1362,12 +1368,6 @@ class InterfaceFilterSet( 'wireless': queryset.filter(type__in=WIRELESS_IFACE_TYPES), }.get(value, queryset.none()) - def filter_is_occupied(self, queryset, name, value): - if value: - return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) - else: - return queryset.filter(cable__isnull=True, mark_connected=False) - class FrontPortFilterSet( ModularDeviceComponentFilterSet, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 15e109030..6cbd91122 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def is_occupied(self): - params = {'is_occupied': 'true'} + def test_is_occupied(self): + params = {'is_occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': 'false'} + params = {'is_occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From 57365ef7b9d012b4c5d27361bfe7c0ca3012db15 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 16:42:04 -0400 Subject: [PATCH 27/30] Rename is_occupied to occupied --- netbox/circuits/tests/test_filtersets.py | 6 +++--- netbox/dcim/filtersets.py | 6 +++--- netbox/dcim/forms/filtersets.py | 4 ++-- netbox/dcim/tests/test_filtersets.py | 6 +++--- 4 files changed, 11 insertions(+), 11 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index ada3d9bf1..0bc0711c1 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -411,10 +411,10 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 3) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 7) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 3af5883ba..afecf551c 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -1144,11 +1144,11 @@ class CabledObjectFilterSet(django_filters.FilterSet): lookup_expr='isnull', exclude=True ) - is_occupied = django_filters.BooleanFilter( - method='filter_is_occupied' + occupied = django_filters.BooleanFilter( + method='filter_occupied' ) - def filter_is_occupied(self, queryset, name, value): + def filter_occupied(self, queryset, name, value): if value: return queryset.filter(Q(cable__isnull=False) | Q(mark_connected=True)) else: diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index fe92350f9..93e221b19 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -1013,7 +1013,7 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - is_occupied = forms.NullBooleanField( + occupied = forms.NullBooleanField( required=False, widget=StaticSelect( choices=BOOLEAN_WITH_BLANK_CHOICES @@ -1030,7 +1030,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'is_occupied')) + ('Connection', ('cabled', 'connected', 'occupied')) ) kind = MultipleChoiceField( choices=InterfaceKindChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6cbd91122..49e68b9a2 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -2885,10 +2885,10 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - def test_is_occupied(self): - params = {'is_occupied': True} + def test_occupied(self): + params = {'occupied': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'is_occupied': False} + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_kind(self): From f10460d774db773c759faa4b9b96c59470c12a5a Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:03:33 -0400 Subject: [PATCH 28/30] Add relevant tests for all device components --- netbox/circuits/tests/test_filtersets.py | 8 +- netbox/dcim/forms/filtersets.py | 157 ++++++++++++----------- netbox/dcim/tests/test_filtersets.py | 128 +++++++++++------- 3 files changed, 167 insertions(+), 126 deletions(-) diff --git a/netbox/circuits/tests/test_filtersets.py b/netbox/circuits/tests/test_filtersets.py index 0bc0711c1..2646de3c2 100644 --- a/netbox/circuits/tests/test_filtersets.py +++ b/netbox/circuits/tests/test_filtersets.py @@ -404,12 +404,8 @@ class CircuitTerminationTestCase(TestCase, ChangeLoggedFilterSetTests): def test_cabled(self): params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) def test_occupied(self): params = {'occupied': True} diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 93e221b19..98be0983e 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -936,70 +936,6 @@ class PowerFeedFilterForm(NetBoxModelFilterSetForm): # Device components # -class ConsolePortFilterForm(DeviceComponentFilterForm): - model = ConsolePort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class ConsoleServerPortFilterForm(DeviceComponentFilterForm): - model = ConsoleServerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type', 'speed')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=ConsolePortTypeChoices, - required=False - ) - speed = MultipleChoiceField( - choices=ConsolePortSpeedChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerPortFilterForm(DeviceComponentFilterForm): - model = PowerPort - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerPortTypeChoices, - required=False - ) - tag = TagFilterField(model) - - -class PowerOutletFilterForm(DeviceComponentFilterForm): - model = PowerOutlet - fieldsets = ( - (None, ('q', 'tag')), - ('Attributes', ('name', 'label', 'type')), - ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ) - type = MultipleChoiceField( - choices=PowerOutletTypeChoices, - required=False - ) - tag = TagFilterField(model) - - class CabledFilterForm(forms.Form): cabled = forms.NullBooleanField( required=False, @@ -1007,12 +943,6 @@ class CabledFilterForm(forms.Form): choices=BOOLEAN_WITH_BLANK_CHOICES ) ) - connected = forms.NullBooleanField( - required=False, - widget=StaticSelect( - choices=BOOLEAN_WITH_BLANK_CHOICES - ) - ) occupied = forms.NullBooleanField( required=False, widget=StaticSelect( @@ -1021,7 +951,84 @@ class CabledFilterForm(forms.Form): ) -class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): +class PathEndpointFilterForm(CabledFilterForm): + connected = forms.NullBooleanField( + required=False, + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + + +class ConsolePortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsolePort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class ConsoleServerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = ConsoleServerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type', 'speed')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=ConsolePortTypeChoices, + required=False + ) + speed = MultipleChoiceField( + choices=ConsolePortSpeedChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerPortFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerPort + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerPortTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class PowerOutletFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): + model = PowerOutlet + fieldsets = ( + (None, ('q', 'tag')), + ('Attributes', ('name', 'label', 'type')), + ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Connection', ('cabled', 'connected', 'occupied')), + ) + type = MultipleChoiceField( + choices=PowerOutletTypeChoices, + required=False + ) + tag = TagFilterField(model) + + +class InterfaceFilterForm(PathEndpointFilterForm, DeviceComponentFilterForm): model = Interface fieldsets = ( (None, ('q', 'tag')), @@ -1030,7 +1037,7 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): ('PoE', ('poe_mode', 'poe_type')), ('Wireless', ('rf_role', 'rf_channel', 'rf_channel_width', 'tx_power')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), - ('Connection', ('cabled', 'connected', 'occupied')) + ('Connection', ('cabled', 'connected', 'occupied')), ) kind = MultipleChoiceField( choices=InterfaceKindChoices, @@ -1111,11 +1118,12 @@ class InterfaceFilterForm(CabledFilterForm, DeviceComponentFilterForm): tag = TagFilterField(model) -class FrontPortFilterForm(DeviceComponentFilterForm): +class FrontPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) model = FrontPort type = MultipleChoiceField( @@ -1128,12 +1136,13 @@ class FrontPortFilterForm(DeviceComponentFilterForm): tag = TagFilterField(model) -class RearPortFilterForm(DeviceComponentFilterForm): +class RearPortFilterForm(CabledFilterForm, DeviceComponentFilterForm): model = RearPort fieldsets = ( (None, ('q', 'tag')), ('Attributes', ('name', 'label', 'type', 'color')), ('Device', ('region_id', 'site_group_id', 'site_id', 'location_id', 'rack_id', 'virtual_chassis_id', 'device_id')), + ('Cable', ('cabled', 'occupied')), ) type = MultipleChoiceField( choices=PortTypeChoices, diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 49e68b9a2..eb4627ac0 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1983,12 +1983,6 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2037,9 +2031,21 @@ class ConsolePortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2144,12 +2150,6 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'description': ['First', 'Second']} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2198,9 +2198,21 @@ class ConsoleServerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2313,12 +2325,6 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'allocated_draw': [50, 100]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2367,9 +2373,21 @@ class PowerPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2478,12 +2496,6 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'feed_leg': [PowerOutletFeedLegChoices.FEED_LEG_A, PowerOutletFeedLegChoices.FEED_LEG_B]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'connected': False} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) - def test_region(self): regions = Region.objects.all()[:2] params = {'region_id': [regions[0].pk, regions[1].pk]} @@ -2532,9 +2544,21 @@ class PowerOutletTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'occupied': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + params = {'connected': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) @@ -2874,15 +2898,9 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - - def test_connected(self): - params = {'connected': True} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'connected': False} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) def test_occupied(self): @@ -2891,6 +2909,12 @@ class InterfaceTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_connected(self): + params = {'connected': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'connected': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + def test_kind(self): params = {'kind': 'physical'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) @@ -3097,9 +3121,15 @@ class FrontPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -3261,9 +3291,15 @@ class RearPortTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'cabled': 'false'} + params = {'cabled': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_occupied(self): + params = {'occupied': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'occupied': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) @@ -4165,9 +4201,9 @@ class PowerFeedTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_cabled(self): - params = {'cabled': 'true'} + params = {'cabled': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'cabled': 'false'} + params = {'cabled': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_connected(self): From 211a1394d3671a9ff8a99bc8071296d13e0cbdfb Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Mon, 12 Sep 2022 17:17:53 -0400 Subject: [PATCH 29/30] Changelog for #8580, #10333 --- docs/release-notes/version-3.3.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index 480595d56..f9fe3d494 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -4,6 +4,7 @@ ### Enhancements +* [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes @@ -16,6 +17,7 @@ * [#10294](https://github.com/netbox-community/netbox/issues/10294) - Fix spurious changelog diff for interface WWN field * [#10304](https://github.com/netbox-community/netbox/issues/10304) - Enable cloning for custom fields & custom links * [#10307](https://github.com/netbox-community/netbox/issues/10307) - Correct value for "Passive 48V (4-pair)" PoE type selection +* [#10333](https://github.com/netbox-community/netbox/issues/10333) - Show available values for `ui_visibility` field of CustomField for CSV import --- From ea9d2e3f88c8560141cba95554595c1b9631b196 Mon Sep 17 00:00:00 2001 From: jeremystretch Date: Tue, 13 Sep 2022 14:14:18 -0400 Subject: [PATCH 30/30] Closes #9577: Add has_front_image and has_rear_image filters for device types --- docs/release-notes/version-3.3.md | 1 + netbox/dcim/filtersets.py | 20 ++++++++++++++++++++ netbox/dcim/forms/filtersets.py | 15 +++++++++++++++ netbox/dcim/tests/test_filtersets.py | 18 +++++++++++++++--- 4 files changed, 51 insertions(+), 3 deletions(-) diff --git a/docs/release-notes/version-3.3.md b/docs/release-notes/version-3.3.md index f9fe3d494..c5ca3d5be 100644 --- a/docs/release-notes/version-3.3.md +++ b/docs/release-notes/version-3.3.md @@ -5,6 +5,7 @@ ### Enhancements * [#8580](https://github.com/netbox-community/netbox/issues/8580) - Add `occupied` filter for cabled objects to filter by cable or `mark_connected` +* [#9577](https://github.com/netbox-community/netbox/issues/9577) - Add `has_front_image` and `has_rear_image` filters for device types * [#10268](https://github.com/netbox-community/netbox/issues/10268) - Omit trailing ".0" in device positions within UI ### Bug Fixes diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index afecf551c..0a4439173 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -434,6 +434,14 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): to_field_name='slug', label='Manufacturer (slug)', ) + has_front_image = django_filters.BooleanFilter( + label='Has a front image', + method='_has_front_image' + ) + has_rear_image = django_filters.BooleanFilter( + label='Has a rear image', + method='_has_rear_image' + ) console_ports = django_filters.BooleanFilter( method='_console_ports', label='Has console ports', @@ -487,6 +495,18 @@ class DeviceTypeFilterSet(NetBoxModelFilterSet): Q(comments__icontains=value) ) + def _has_front_image(self, queryset, name, value): + if value: + return queryset.exclude(front_image='') + else: + return queryset.filter(front_image='') + + def _has_rear_image(self, queryset, name, value): + if value: + return queryset.exclude(rear_image='') + else: + return queryset.filter(rear_image='') + def _console_ports(self, queryset, name, value): return queryset.exclude(consoleporttemplates__isnull=value) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index 98be0983e..96b0d1319 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -365,6 +365,7 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): fieldsets = ( (None, ('q', 'tag')), ('Hardware', ('manufacturer_id', 'part_number', 'subdevice_role', 'airflow')), + ('Images', ('has_front_image', 'has_rear_image')), ('Components', ( 'console_ports', 'console_server_ports', 'power_ports', 'power_outlets', 'interfaces', 'pass_through_ports', 'device_bays', 'module_bays', 'inventory_items', @@ -386,6 +387,20 @@ class DeviceTypeFilterForm(NetBoxModelFilterSetForm): choices=add_blank_choice(DeviceAirflowChoices), required=False ) + has_front_image = forms.NullBooleanField( + required=False, + label='Has a front image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) + has_rear_image = forms.NullBooleanField( + required=False, + label='Has a rear image', + widget=StaticSelect( + choices=BOOLEAN_WITH_BLANK_CHOICES + ) + ) console_ports = forms.NullBooleanField( required=False, label='Has console ports', diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index eb4627ac0..feef4e90c 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -688,7 +688,7 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): Manufacturer.objects.bulk_create(manufacturers) device_types = ( - DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True), + DeviceType(manufacturer=manufacturers[0], model='Model 1', slug='model-1', part_number='Part Number 1', u_height=1, is_full_depth=True, front_image='front.png', rear_image='rear.png'), DeviceType(manufacturer=manufacturers[1], model='Model 2', slug='model-2', part_number='Part Number 2', u_height=2, is_full_depth=True, subdevice_role=SubdeviceRoleChoices.ROLE_PARENT, airflow=DeviceAirflowChoices.AIRFLOW_FRONT_TO_REAR), DeviceType(manufacturer=manufacturers[2], model='Model 3', slug='model-3', part_number='Part Number 3', u_height=3, is_full_depth=False, subdevice_role=SubdeviceRoleChoices.ROLE_CHILD, airflow=DeviceAirflowChoices.AIRFLOW_REAR_TO_FRONT), ) @@ -753,9 +753,9 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) def test_is_full_depth(self): - params = {'is_full_depth': 'true'} + params = {'is_full_depth': True} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) - params = {'is_full_depth': 'false'} + params = {'is_full_depth': False} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) def test_subdevice_role(self): @@ -773,6 +773,18 @@ class DeviceTypeTestCase(TestCase, ChangeLoggedFilterSetTests): params = {'manufacturer': [manufacturers[0].slug, manufacturers[1].slug]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_has_front_image(self): + params = {'has_front_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_front_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + + def test_has_rear_image(self): + params = {'has_rear_image': True} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 1) + params = {'has_rear_image': False} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2) + def test_console_ports(self): params = {'console_ports': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 2)