diff --git a/docs/release-notes/version-2.10.md b/docs/release-notes/version-2.10.md index 4c643a23a..daa5c0eae 100644 --- a/docs/release-notes/version-2.10.md +++ b/docs/release-notes/version-2.10.md @@ -19,6 +19,7 @@ * [#5498](https://github.com/netbox-community/netbox/issues/5498) - Fix filtering rack reservations by username * [#5499](https://github.com/netbox-community/netbox/issues/5499) - Fix filtering of displayed device/VM interfaces by regex * [#5507](https://github.com/netbox-community/netbox/issues/5507) - Fix custom field data assignment via UI for IP addresses, secrets +* [#5510](https://github.com/netbox-community/netbox/issues/5510) - Fix filtering by boolean custom fields --- diff --git a/netbox/extras/filters.py b/netbox/extras/filters.py index 7b341f74d..e3c313735 100644 --- a/netbox/extras/filters.py +++ b/netbox/extras/filters.py @@ -2,6 +2,7 @@ import django_filters from django.contrib.auth.models import User from django.contrib.contenttypes.models import ContentType from django.db.models import Q +from django.forms import DateField, IntegerField, NullBooleanField from dcim.models import DeviceRole, Platform, Region, Site from tenancy.models import Tenant, TenantGroup @@ -38,24 +39,21 @@ class CustomFieldFilter(django_filters.Filter): """ def __init__(self, custom_field, *args, **kwargs): self.custom_field = custom_field + + if custom_field.type == CustomFieldTypeChoices.TYPE_INTEGER: + self.field_class = IntegerField + elif custom_field.type == CustomFieldTypeChoices.TYPE_BOOLEAN: + self.field_class = NullBooleanField + elif custom_field.type == CustomFieldTypeChoices.TYPE_DATE: + self.field_class = DateField + super().__init__(*args, **kwargs) - def filter(self, queryset, value): + self.field_name = f'custom_field_data__{self.field_name}' - # Skip filter on empty value - if value is None or not value.strip(): - return queryset - - # Apply the assigned filter logic (exact or loose) - if ( - self.custom_field.type in EXACT_FILTER_TYPES or - self.custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_EXACT - ): - kwargs = {f'custom_field_data__{self.field_name}': value} - else: - kwargs = {f'custom_field_data__{self.field_name}__icontains': value} - - return queryset.filter(**kwargs) + if custom_field.type not in EXACT_FILTER_TYPES: + if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE: + self.lookup_expr = 'icontains' class CustomFieldModelFilterSet(django_filters.FilterSet): diff --git a/netbox/extras/tests/test_customfields.py b/netbox/extras/tests/test_customfields.py index fe56027dc..4f7a67676 100644 --- a/netbox/extras/tests/test_customfields.py +++ b/netbox/extras/tests/test_customfields.py @@ -3,6 +3,7 @@ from django.core.exceptions import ValidationError from django.urls import reverse from rest_framework import status +from dcim.filters import SiteFilterSet from dcim.forms import SiteCSVForm from dcim.models import Site, Rack from extras.choices import * @@ -597,3 +598,102 @@ class CustomFieldModelTest(TestCase): site.cf['baz'] = 'def' site.clean() + + +class CustomFieldFilterTest(TestCase): + queryset = Site.objects.all() + filterset = SiteFilterSet + + @classmethod + def setUpTestData(cls): + obj_type = ContentType.objects.get_for_model(Site) + + # Integer filtering + cf = CustomField(name='cf1', type=CustomFieldTypeChoices.TYPE_INTEGER) + cf.save() + cf.content_types.set([obj_type]) + + # Boolean filtering + cf = CustomField(name='cf2', type=CustomFieldTypeChoices.TYPE_BOOLEAN) + cf.save() + cf.content_types.set([obj_type]) + + # Exact text filtering + cf = CustomField(name='cf3', type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf.save() + cf.content_types.set([obj_type]) + + # Loose text filtering + cf = CustomField(name='cf4', type=CustomFieldTypeChoices.TYPE_TEXT, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf.save() + cf.content_types.set([obj_type]) + + # Date filtering + cf = CustomField(name='cf5', type=CustomFieldTypeChoices.TYPE_DATE) + cf.save() + cf.content_types.set([obj_type]) + + # Exact URL filtering + cf = CustomField(name='cf6', type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_EXACT) + cf.save() + cf.content_types.set([obj_type]) + + # Loose URL filtering + cf = CustomField(name='cf7', type=CustomFieldTypeChoices.TYPE_URL, + filter_logic=CustomFieldFilterLogicChoices.FILTER_LOOSE) + cf.save() + cf.content_types.set([obj_type]) + + # Selection filtering + cf = CustomField(name='cf8', type=CustomFieldTypeChoices.TYPE_URL, choices=['Foo', 'Bar', 'Baz']) + cf.save() + cf.content_types.set([obj_type]) + + Site.objects.bulk_create([ + Site(name='Site 1', slug='site-1', custom_field_data={ + 'cf1': 100, + 'cf2': True, + 'cf3': 'foo', + 'cf4': 'foo', + 'cf5': '2016-06-26', + 'cf6': 'http://foo.example.com/', + 'cf7': 'http://foo.example.com/', + 'cf8': 'Foo', + }), + Site(name='Site 2', slug='site-2', custom_field_data={ + 'cf1': 200, + 'cf2': False, + 'cf3': 'foobar', + 'cf4': 'foobar', + 'cf5': '2016-06-27', + 'cf6': 'http://bar.example.com/', + 'cf7': 'http://bar.example.com/', + 'cf8': 'Bar', + }), + Site(name='Site 3', slug='site-3', custom_field_data={ + }), + ]) + + def test_filter_integer(self): + self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1) + + def test_filter_boolean(self): + self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1) + + def test_filter_text(self): + self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2) + + def test_filter_date(self): + self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1) + + def test_filter_url(self): + self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1) + self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2) + + def test_filter_select(self): + self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)