diff --git a/netbox/dcim/choices.py b/netbox/dcim/choices.py index c90a0b3ead1..78271a9c27c 100644 --- a/netbox/dcim/choices.py +++ b/netbox/dcim/choices.py @@ -1504,8 +1504,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 6517aadb45b..e2672f551cb 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 e2d52a60942..723a63a0976 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 * @@ -5247,6 +5248,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': [settings.FILTERS_NULL_CHOICE_VALUE]} + 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) def test_status(self): params = {'status': [LinkStatusChoices.STATUS_CONNECTED]} diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index ac43fe57f44..e3bd332983c 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -133,7 +133,7 @@ def _get_filter_lookup_dict(existing_filter): django_filters.ModelChoiceFilter, django_filters.ModelMultipleChoiceFilter, TagFilter - )) or existing_filter.extra.get('choices'): + )): # These filter types support only negation return FILTER_NEGATION_LOOKUP_MAP @@ -172,6 +172,7 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): # Create new filters for each lookup expression in the map for lookup_name, lookup_expr in lookup_map.items(): new_filter_name = f'{existing_filter_name}__{lookup_name}' + existing_filter_extra = deepcopy(existing_filter.extra) try: if existing_filter_name in cls.declared_filters: @@ -179,6 +180,8 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): # create the new filter with the same type because there is no guarantee the defined type # is the same as the default type for the field resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid + for field_to_remove in ('choices', 'null_value'): + existing_filter_extra.pop(field_to_remove, None) filter_cls = django_filters.BooleanFilter if lookup_expr == 'empty' else type(existing_filter) new_filter = filter_cls( field_name=field_name, @@ -186,7 +189,7 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): label=existing_filter.label, exclude=existing_filter.exclude, distinct=existing_filter.distinct, - **existing_filter.extra + **existing_filter_extra ) elif hasattr(existing_filter, 'custom_field'): # Filter is for a custom field 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