diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index 11d1927323a..19ddb28e2c0 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1514,8 +1514,12 @@ class CableTypeChoices(ChoiceSet): (TYPE_AOC, 'Active Optical Cabling (AOC)'), ), ), - (TYPE_USB, _('USB')), - (TYPE_POWER, _('Power')), + ( + _('Other'), ( + (TYPE_USB, _('USB')), + (TYPE_POWER, _('Power')), + ) + ) ) diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5a101e739b7..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, + 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 = django_filters.MultipleChoiceFilter( + type = NullableMultipleChoiceFilter( choices=CableTypeChoices ) status = django_filters.MultipleChoiceFilter( 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/dcim/tests/test_filtersets.py b/netbox/dcim/tests/test_filtersets.py index 6c65cad9302..53c7c68c7e5 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,10 +5241,10 @@ 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__empty': 'true'} + params = {'type': [settings.FILTERS_NULL_CHOICE_VALUE]} 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': [settings.FILTERS_NULL_CHOICE_VALUE, 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 05454543e3c..f51fa23ff6d 100644 --- a/netbox/utilities/filters.py +++ b/netbox/utilities/filters.py @@ -18,6 +18,7 @@ 'MultiValueTimeFilter', 'MultiValueWWNFilter', 'NullableCharFieldFilter', + 'NullableMultipleChoiceFilter', 'NumericArrayFilter', 'TreeNodeMultipleChoiceFilter', ) @@ -143,6 +144,16 @@ def filter(self, qs, value): return qs.distinct() if self.distinct else qs +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('') + return super().filter(qs, value) + + class NumericArrayFilter(django_filters.NumberFilter): """ Filter based on the presence of an integer within an ArrayField. 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