From 2c9eeb8d21ebd0bb9dc0db086be41194fb6ff27f Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Thu, 12 Sep 2024 15:41:39 -0400 Subject: [PATCH 1/8] Add EmptyStringFilter and type__empty filter on CableFilterSet --- netbox/dcim/filtersets.py | 5 ++++- netbox/dcim/tests/test_filtersets.py | 6 ++++++ netbox/utilities/filters.py | 11 +++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5a101e739b7..e08059b8581 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,7 +18,7 @@ from users.models import User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - NumericArrayFilter, TreeNodeMultipleChoiceFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, EmptyStringFilter, ) from virtualization.models import Cluster, ClusterGroup from vpn.models import L2VPN @@ -1983,6 +1983,9 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): type = django_filters.MultipleChoiceFilter( choices=CableTypeChoices ) + type__empty = EmptyStringFilter( + field_name='type' + ) status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6c65cad9302..56f54b24997 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5245,6 +5245,12 @@ def test_type(self): params = {'type__empty': 'false'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_type_empty(self): + params = {'type__empty': 'true'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) + params = {'type__empty': 'false'} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + def test_status(self): params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 11) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 05454543e3c..95c615dcbdc 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -171,3 +171,14 @@ def filter(self, qs, value): f'{self.field_name}__model': model } ) + + +class EmptyStringFilter(django_filters.BooleanFilter): + def filter(self, qs, value): + if value in EMPTY_VALUES: + return qs + + exclude = self.exclude ^ (value is False) + method = qs.exclude if exclude else qs.filter + + return method(**{self.field_name: ""}) From 05ee5a5145ac50d28c983cd5eca62f33cf65b300 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 13 Sep 2024 08:52:17 -0400 Subject: [PATCH 2/8] Change to EmptyStringMultipleChoiceFilter --- netbox/dcim/choices.py | 10 ++++++++-- netbox/dcim/filtersets.py | 7 ++----- netbox/dcim/tests/test_filtersets.py | 5 +++-- netbox/utilities/filters.py | 14 ++++++-------- 4 files changed, 19 insertions(+), 17 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 11d1927323a..0afbb26b9f1 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1482,6 +1482,7 @@ class CableTypeChoices(ChoiceSet): TYPE_AOC = 'aoc' TYPE_POWER = 'power' TYPE_USB = 'usb' + TYPE_EMPTY = 'EMPTY' CHOICES = ( ( @@ -1514,8 +1515,13 @@ class CableTypeChoices(ChoiceSet): (TYPE_AOC, 'Active Optical Cabling (AOC)'), ), ), - (TYPE_USB, _('USB')), - (TYPE_POWER, _('Power')), + ( + _('Other'), ( + (TYPE_USB, _('USB')), + (TYPE_POWER, _('Power')), + (TYPE_EMPTY, _('(unset)')), + ) + ) ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index e08059b8581..b8b05bb42df 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,7 +18,7 @@ from users.models import User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - NumericArrayFilter, TreeNodeMultipleChoiceFilter, EmptyStringFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, EmptyStringMultipleChoiceFilter, ) from virtualization.models import Cluster, ClusterGroup from vpn.models import L2VPN @@ -1980,12 +1980,9 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): method='_unterminated', label=_('Unterminated'), ) - type = django_filters.MultipleChoiceFilter( + type = EmptyStringMultipleChoiceFilter( choices=CableTypeChoices ) - type__empty = EmptyStringFilter( - field_name='type' - ) status = django_filters.MultipleChoiceFilter( choices=LinkStatusChoices ) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 56f54b24997..26c8f30c399 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5240,6 +5240,7 @@ def test_length_unit(self): def test_type(self): params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) + params = {'type': [CableTypeChoices.TYPE_EMPTY]} params = {'type__empty': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) params = {'type__empty': 'false'} @@ -5248,8 +5249,8 @@ def test_type(self): def test_type_empty(self): params = {'type__empty': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) - params = {'type__empty': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) + params = {'type': [CableTypeChoices.TYPE_EMPTY, CableTypeChoices.TYPE_CAT3]} + self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) def test_status(self): params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 95c615dcbdc..3d8203b856a 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -173,12 +173,10 @@ def filter(self, qs, value): ) -class EmptyStringFilter(django_filters.BooleanFilter): - def filter(self, qs, value): - if value in EMPTY_VALUES: - return qs +class EmptyStringMultipleChoiceFilter(django_filters.MultipleChoiceFilter): + empty_value = 'EMPTY' - exclude = self.exclude ^ (value is False) - method = qs.exclude if exclude else qs.filter - - return method(**{self.field_name: ""}) + def filter(self, qs, value): + if self.empty_value in value: + value.append('') + return super().filter(qs, value) From 98ba553466563eb59088646114afdaadfd87955c Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 13 Sep 2024 09:05:27 -0400 Subject: [PATCH 3/8] Change to NullableMultipleChoiceFilter --- netbox/dcim/choices.py | 5 +++-- netbox/dcim/filtersets.py | 4 ++-- netbox/utilities/filters.py | 17 ++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 0afbb26b9f1..2fecfcb5fe3 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.utils.translation import gettext_lazy as _ from utilities.choices import ChoiceSet @@ -1482,7 +1483,7 @@ class CableTypeChoices(ChoiceSet): TYPE_AOC = 'aoc' TYPE_POWER = 'power' TYPE_USB = 'usb' - TYPE_EMPTY = 'EMPTY' + TYPE_EMPTY = settings.FILTERS_NULL_CHOICE_VALUE CHOICES = ( ( @@ -1519,7 +1520,7 @@ class CableTypeChoices(ChoiceSet): _('Other'), ( (TYPE_USB, _('USB')), (TYPE_POWER, _('Power')), - (TYPE_EMPTY, _('(unset)')), + (settings.FILTERS_NULL_CHOICE_VALUE, _('(unset)')), ) ) ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index b8b05bb42df..ef489711eca 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -18,7 +18,7 @@ from users.models import User from utilities.filters import ( ContentTypeFilter, MultiValueCharFilter, MultiValueMACAddressFilter, MultiValueNumberFilter, MultiValueWWNFilter, - NumericArrayFilter, TreeNodeMultipleChoiceFilter, EmptyStringMultipleChoiceFilter, + NumericArrayFilter, TreeNodeMultipleChoiceFilter, NullableMultipleChoiceFilter, ) from virtualization.models import Cluster, ClusterGroup from vpn.models import L2VPN @@ -1980,7 +1980,7 @@ class CableFilterSet(TenancyFilterSet, NetBoxModelFilterSet): method='_unterminated', label=_('Unterminated'), ) - type = EmptyStringMultipleChoiceFilter( + type = NullableMultipleChoiceFilter( choices=CableTypeChoices ) status = django_filters.MultipleChoiceFilter( diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 3d8203b856a..66df2e54f02 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -143,6 +143,14 @@ def filter(self, qs, value): return qs.distinct() if self.distinct else qs +class NullableMultipleChoiceFilter(django_filters.MultipleChoiceFilter): + + def filter(self, qs, value): + if settings.FILTERS_NULL_CHOICE_VALUE in value: + value.append('') + return super().filter(qs, value) + + class NumericArrayFilter(django_filters.NumberFilter): """ Filter based on the presence of an integer within an ArrayField. @@ -171,12 +179,3 @@ def filter(self, qs, value): f'{self.field_name}__model': model } ) - - -class EmptyStringMultipleChoiceFilter(django_filters.MultipleChoiceFilter): - empty_value = 'EMPTY' - - def filter(self, qs, value): - if self.empty_value in value: - value.append('') - return super().filter(qs, value) From 4f9e7db23f1a79df69a5b71466263ce6466cc0f0 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 13 Sep 2024 09:13:58 -0400 Subject: [PATCH 4/8] Add docstring for NullableMultipleChoiceFilter --- netbox/utilities/filters.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/netbox/utilities/filters.py b/netbox/utilities/filters.py index 66df2e54f02..f51fa23ff6d 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -18,6 +18,7 @@ 'MultiValueTimeFilter', 'MultiValueWWNFilter', 'NullableCharFieldFilter', + 'NullableMultipleChoiceFilter', 'NumericArrayFilter', 'TreeNodeMultipleChoiceFilter', ) @@ -144,7 +145,9 @@ def filter(self, qs, value): class NullableMultipleChoiceFilter(django_filters.MultipleChoiceFilter): - + """ + Similar to NullableCharFieldFilter, but allows multiple values including the special NULL string. + """ def filter(self, qs, value): if settings.FILTERS_NULL_CHOICE_VALUE in value: value.append('') From 1c6ee01a552c6b79ba56d4e830c46178b173341e Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 18 Sep 2024 16:14:04 -0400 Subject: [PATCH 5/8] Add add_empty_filtering_choice util for empty-value filtering --- netbox/dcim/choices.py | 2 -- netbox/dcim/forms/filtersets.py | 4 ++-- netbox/utilities/forms/utils.py | 10 ++++++++++ 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 2fecfcb5fe3..f6ed5da5ffd 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1483,7 +1483,6 @@ class CableTypeChoices(ChoiceSet): TYPE_AOC = 'aoc' TYPE_POWER = 'power' TYPE_USB = 'usb' - TYPE_EMPTY = settings.FILTERS_NULL_CHOICE_VALUE CHOICES = ( ( @@ -1520,7 +1519,6 @@ class CableTypeChoices(ChoiceSet): _('Other'), ( (TYPE_USB, _('USB')), (TYPE_POWER, _('Power')), - (settings.FILTERS_NULL_CHOICE_VALUE, _('(unset)')), ) ) ) diff --git a/netbox/dcim/forms/filtersets.py b/netbox/dcim/forms/filtersets.py index e2b6fda07a7..64814cf8ba7 100644 --- a/netbox/dcim/forms/filtersets.py +++ b/netbox/dcim/forms/filtersets.py @@ -10,7 +10,7 @@ from netbox.forms import NetBoxModelFilterSetForm from tenancy.forms import ContactModelFilterForm, TenancyFilterForm from users.models import User -from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice +from utilities.forms import BOOLEAN_WITH_BLANK_CHOICES, FilterForm, add_blank_choice, add_empty_filtering_choice from utilities.forms.fields import ColorField, DynamicModelMultipleChoiceField, TagFilterField from utilities.forms.rendering import FieldSet from utilities.forms.widgets import NumberWithOptions @@ -1052,7 +1052,7 @@ class CableFilterForm(TenancyFilterForm, NetBoxModelFilterSetForm): ) type = forms.MultipleChoiceField( label=_('Type'), - choices=add_blank_choice(CableTypeChoices), + choices=add_empty_filtering_choice(add_blank_choice(CableTypeChoices)), required=False ) status = forms.MultipleChoiceField( diff --git a/netbox/utilities/forms/utils.py b/netbox/utilities/forms/utils.py index 0429fe5710e..56d13b058cb 100644 --- a/netbox/utilities/forms/utils.py +++ b/netbox/utilities/forms/utils.py @@ -1,6 +1,7 @@ import re from django import forms +from django.conf import settings from django.forms.models import fields_for_model from django.utils.translation import gettext as _ @@ -10,6 +11,7 @@ __all__ = ( 'add_blank_choice', + 'add_empty_filtering_choice', 'expand_alphanumeric_pattern', 'expand_ipaddress_pattern', 'form_from_model', @@ -189,6 +191,14 @@ def add_blank_choice(choices): return ((None, '---------'),) + tuple(choices) +def add_empty_filtering_choice(choices): + """ + Add an empty (null) choice to the end of a choices list, to be used in filtering classes + such as NullableMultipleChoiceFilter to match on an empty value. + """ + return tuple(choices) + ((settings.FILTERS_NULL_CHOICE_VALUE, '(unset)'),) + + def form_from_model(model, fields): """ Return a Form class with the specified fields derived from a model. This is useful when we need a form to be used From 702b4df8bbc5f67b8a83d211e704d7c9454df391 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 18 Sep 2024 16:15:16 -0400 Subject: [PATCH 6/8] Remove unneeded import --- netbox/dcim/choices.py | 1 - 1 file changed, 1 deletion(-) diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index f6ed5da5ffd..19ddb28e2c0 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1,4 +1,3 @@ -from django.conf import settings from django.utils.translation import gettext_lazy as _ from utilities.choices import ChoiceSet From 240ab4e44fbd94a6e30769335e46f04262f34328 Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Wed, 18 Sep 2024 16:58:46 -0400 Subject: [PATCH 7/8] Fix tests --- netbox/dcim/tests/test_filtersets.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 26c8f30c399..ff83f1e35ce 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -1,4 +1,5 @@ from django.test import TestCase +from django.conf import settings from circuits.models import Circuit, CircuitTermination, CircuitType, Provider from dcim.choices import * @@ -5240,7 +5241,7 @@ def test_length_unit(self): def test_type(self): params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) - params = {'type': [CableTypeChoices.TYPE_EMPTY]} + params = {'type': [settings.FILTERS_NULL_CHOICE_VALUE]} params = {'type__empty': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) params = {'type__empty': 'false'} @@ -5249,7 +5250,7 @@ def test_type(self): def test_type_empty(self): params = {'type__empty': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) - params = {'type': [CableTypeChoices.TYPE_EMPTY, CableTypeChoices.TYPE_CAT3]} + params = {'type': [settings.FILTERS_NULL_CHOICE_VALUE, CableTypeChoices.TYPE_CAT3]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10) def test_status(self): From c2473e6f9e2212eede0adad3f2662accbf819f5a Mon Sep 17 00:00:00 2001 From: Brian Tiemann Date: Fri, 11 Oct 2024 13:44:13 -0400 Subject: [PATCH 8/8] Combine unit tests for Cable.type --- netbox/dcim/tests/test_filtersets.py | 7 ------- 1 file changed, 7 deletions(-) diff --git a/netbox/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index ff83f1e35ce..53c7c68c7e5 100644 --- a/netbox/dcim/tests/test_filtersets.py +++ b/netbox/dcim/tests/test_filtersets.py @@ -5242,13 +5242,6 @@ def test_type(self): params = {'type': [CableTypeChoices.TYPE_CAT3, CableTypeChoices.TYPE_CAT5E]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 4) params = {'type': [settings.FILTERS_NULL_CHOICE_VALUE]} - params = {'type__empty': 'true'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) - params = {'type__empty': 'false'} - self.assertEqual(self.filterset(params, self.queryset).qs.count(), 6) - - def test_type_empty(self): - params = {'type__empty': 'true'} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 8) params = {'type': [settings.FILTERS_NULL_CHOICE_VALUE, CableTypeChoices.TYPE_CAT3]} self.assertEqual(self.filterset(params, self.queryset).qs.count(), 10)