mirror of
https://github.com/netbox-community/netbox.git
synced 2025-07-21 19:47:20 -06:00
Merge pull request #7678 from netbox-community/6615-custom-field-filters
Closes #6615: Enable filter lookups for custom fields
This commit is contained in:
commit
f420435b82
@ -1,4 +1,3 @@
|
|||||||
from .fields import *
|
|
||||||
from .models import *
|
from .models import *
|
||||||
from .filtersets import *
|
from .filtersets import *
|
||||||
from .object_create import *
|
from .object_create import *
|
||||||
|
@ -1,25 +0,0 @@
|
|||||||
from django import forms
|
|
||||||
from netaddr import EUI
|
|
||||||
from netaddr.core import AddrFormatError
|
|
||||||
|
|
||||||
__all__ = (
|
|
||||||
'MACAddressField',
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class MACAddressField(forms.Field):
|
|
||||||
widget = forms.CharField
|
|
||||||
default_error_messages = {
|
|
||||||
'invalid': 'MAC address must be in EUI-48 format',
|
|
||||||
}
|
|
||||||
|
|
||||||
def to_python(self, value):
|
|
||||||
value = super().to_python(value)
|
|
||||||
|
|
||||||
# Validate MAC address format
|
|
||||||
try:
|
|
||||||
value = EUI(value.strip())
|
|
||||||
except AddrFormatError:
|
|
||||||
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
|
|
||||||
|
|
||||||
return value
|
|
@ -1,47 +1,11 @@
|
|||||||
import django_filters
|
import django_filters
|
||||||
from django.forms import DateField, IntegerField, NullBooleanField
|
|
||||||
|
|
||||||
from .models import Tag
|
from .models import Tag
|
||||||
from .choices import *
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'CustomFieldFilter',
|
|
||||||
'TagFilter',
|
'TagFilter',
|
||||||
)
|
)
|
||||||
|
|
||||||
EXACT_FILTER_TYPES = (
|
|
||||||
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
|
||||||
CustomFieldTypeChoices.TYPE_DATE,
|
|
||||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
|
||||||
CustomFieldTypeChoices.TYPE_MULTISELECT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilter(django_filters.Filter):
|
|
||||||
"""
|
|
||||||
Filter objects by the presence of a CustomFieldValue. The filter's name is used as the CustomField name.
|
|
||||||
"""
|
|
||||||
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)
|
|
||||||
|
|
||||||
self.field_name = f'custom_field_data__{self.field_name}'
|
|
||||||
|
|
||||||
if custom_field.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
|
||||||
self.lookup_expr = 'has_key'
|
|
||||||
elif custom_field.type not in EXACT_FILTER_TYPES:
|
|
||||||
if custom_field.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
|
||||||
self.lookup_expr = 'icontains'
|
|
||||||
|
|
||||||
|
|
||||||
class TagFilter(django_filters.ModelMultipleChoiceFilter):
|
class TagFilter(django_filters.ModelMultipleChoiceFilter):
|
||||||
"""
|
"""
|
||||||
|
@ -26,13 +26,6 @@ __all__ = (
|
|||||||
'WebhookFilterSet',
|
'WebhookFilterSet',
|
||||||
)
|
)
|
||||||
|
|
||||||
EXACT_FILTER_TYPES = (
|
|
||||||
CustomFieldTypeChoices.TYPE_BOOLEAN,
|
|
||||||
CustomFieldTypeChoices.TYPE_DATE,
|
|
||||||
CustomFieldTypeChoices.TYPE_INTEGER,
|
|
||||||
CustomFieldTypeChoices.TYPE_SELECT,
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
class WebhookFilterSet(BaseFilterSet):
|
class WebhookFilterSet(BaseFilterSet):
|
||||||
content_types = ContentTypeFilter()
|
content_types = ContentTypeFilter()
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
import re
|
import re
|
||||||
from datetime import datetime, date
|
from datetime import datetime, date
|
||||||
|
|
||||||
|
import django_filters
|
||||||
from django import forms
|
from django import forms
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.postgres.fields import ArrayField
|
from django.contrib.postgres.fields import ArrayField
|
||||||
@ -12,6 +13,7 @@ from django.utils.safestring import mark_safe
|
|||||||
from extras.choices import *
|
from extras.choices import *
|
||||||
from extras.utils import FeatureQuery, extras_features
|
from extras.utils import FeatureQuery, extras_features
|
||||||
from netbox.models import ChangeLoggedModel
|
from netbox.models import ChangeLoggedModel
|
||||||
|
from utilities import filters
|
||||||
from utilities.forms import (
|
from utilities.forms import (
|
||||||
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
CSVChoiceField, DatePicker, LaxURLField, StaticSelectMultiple, StaticSelect, add_blank_choice,
|
||||||
)
|
)
|
||||||
@ -308,6 +310,58 @@ class CustomField(ChangeLoggedModel):
|
|||||||
|
|
||||||
return field
|
return field
|
||||||
|
|
||||||
|
def to_filter(self, lookup_expr=None):
|
||||||
|
"""
|
||||||
|
Return a django_filters Filter instance suitable for this field type.
|
||||||
|
|
||||||
|
:param lookup_expr: Custom lookup expression (optional)
|
||||||
|
"""
|
||||||
|
kwargs = {
|
||||||
|
'field_name': f'custom_field_data__{self.name}'
|
||||||
|
}
|
||||||
|
if lookup_expr is not None:
|
||||||
|
kwargs['lookup_expr'] = lookup_expr
|
||||||
|
|
||||||
|
# Text/URL
|
||||||
|
if self.type in (
|
||||||
|
CustomFieldTypeChoices.TYPE_TEXT,
|
||||||
|
CustomFieldTypeChoices.TYPE_LONGTEXT,
|
||||||
|
CustomFieldTypeChoices.TYPE_URL,
|
||||||
|
):
|
||||||
|
filter_class = filters.MultiValueCharFilter
|
||||||
|
if self.filter_logic == CustomFieldFilterLogicChoices.FILTER_LOOSE:
|
||||||
|
kwargs['lookup_expr'] = 'icontains'
|
||||||
|
|
||||||
|
# Integer
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_INTEGER:
|
||||||
|
filter_class = filters.MultiValueNumberFilter
|
||||||
|
|
||||||
|
# Boolean
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_BOOLEAN:
|
||||||
|
filter_class = django_filters.BooleanFilter
|
||||||
|
|
||||||
|
# Date
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_DATE:
|
||||||
|
filter_class = filters.MultiValueDateFilter
|
||||||
|
|
||||||
|
# Select
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_SELECT:
|
||||||
|
filter_class = filters.MultiValueCharFilter
|
||||||
|
|
||||||
|
# Multiselect
|
||||||
|
elif self.type == CustomFieldTypeChoices.TYPE_MULTISELECT:
|
||||||
|
filter_class = filters.MultiValueCharFilter
|
||||||
|
kwargs['lookup_expr'] = 'has_key'
|
||||||
|
|
||||||
|
# Unsupported custom field type
|
||||||
|
else:
|
||||||
|
return None
|
||||||
|
|
||||||
|
filter_instance = filter_class(**kwargs)
|
||||||
|
filter_instance.custom_field = self
|
||||||
|
|
||||||
|
return filter_instance
|
||||||
|
|
||||||
def validate(self, value):
|
def validate(self, value):
|
||||||
"""
|
"""
|
||||||
Validate a value according to the field's type validation rules.
|
Validate a value according to the field's type validation rules.
|
||||||
|
@ -719,7 +719,7 @@ class CustomFieldModelTest(TestCase):
|
|||||||
site.clean()
|
site.clean()
|
||||||
|
|
||||||
|
|
||||||
class CustomFieldFilterTest(TestCase):
|
class CustomFieldModelFilterTest(TestCase):
|
||||||
queryset = Site.objects.all()
|
queryset = Site.objects.all()
|
||||||
filterset = SiteFilterSet
|
filterset = SiteFilterSet
|
||||||
|
|
||||||
@ -772,7 +772,7 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
# Multiselect filtering
|
# Multiselect filtering
|
||||||
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'AA', 'B', 'C'])
|
cf = CustomField(name='cf9', type=CustomFieldTypeChoices.TYPE_MULTISELECT, choices=['A', 'B', 'C', 'X'])
|
||||||
cf.save()
|
cf.save()
|
||||||
cf.content_types.set([obj_type])
|
cf.content_types.set([obj_type])
|
||||||
|
|
||||||
@ -783,49 +783,88 @@ class CustomFieldFilterTest(TestCase):
|
|||||||
'cf3': 'foo',
|
'cf3': 'foo',
|
||||||
'cf4': 'foo',
|
'cf4': 'foo',
|
||||||
'cf5': '2016-06-26',
|
'cf5': '2016-06-26',
|
||||||
'cf6': 'http://foo.example.com/',
|
'cf6': 'http://a.example.com',
|
||||||
'cf7': 'http://foo.example.com/',
|
'cf7': 'http://a.example.com',
|
||||||
'cf8': 'Foo',
|
'cf8': 'Foo',
|
||||||
'cf9': ['A', 'B'],
|
'cf9': ['A', 'X'],
|
||||||
}),
|
}),
|
||||||
Site(name='Site 2', slug='site-2', custom_field_data={
|
Site(name='Site 2', slug='site-2', custom_field_data={
|
||||||
'cf1': 200,
|
'cf1': 200,
|
||||||
'cf2': False,
|
'cf2': True,
|
||||||
'cf3': 'foobar',
|
'cf3': 'foobar',
|
||||||
'cf4': 'foobar',
|
'cf4': 'foobar',
|
||||||
'cf5': '2016-06-27',
|
'cf5': '2016-06-27',
|
||||||
'cf6': 'http://bar.example.com/',
|
'cf6': 'http://b.example.com',
|
||||||
'cf7': 'http://bar.example.com/',
|
'cf7': 'http://b.example.com',
|
||||||
'cf8': 'Bar',
|
'cf8': 'Bar',
|
||||||
'cf9': ['AA', 'B'],
|
'cf9': ['B', 'X'],
|
||||||
|
}),
|
||||||
|
Site(name='Site 3', slug='site-3', custom_field_data={
|
||||||
|
'cf1': 300,
|
||||||
|
'cf2': False,
|
||||||
|
'cf3': 'bar',
|
||||||
|
'cf4': 'bar',
|
||||||
|
'cf5': '2016-06-28',
|
||||||
|
'cf6': 'http://c.example.com',
|
||||||
|
'cf7': 'http://c.example.com',
|
||||||
|
'cf8': 'Baz',
|
||||||
|
'cf9': ['C', 'X'],
|
||||||
}),
|
}),
|
||||||
Site(name='Site 3', slug='site-3'),
|
|
||||||
])
|
])
|
||||||
|
|
||||||
def test_filter_integer(self):
|
def test_filter_integer(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf1': 100}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf1': [100, 200]}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf1__n': [200]}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf1__gt': [200]}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf1__gte': [200]}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf1__lt': [200]}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf1__lte': [200]}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_filter_boolean(self):
|
def test_filter_boolean(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf2': True}, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf2': False}, self.queryset).qs.count(), 1)
|
||||||
|
|
||||||
def test_filter_text(self):
|
def test_filter_text_strict(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf3': 'foo'}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf3': ['foo']}, self.queryset).qs.count(), 1)
|
||||||
self.assertEqual(self.filterset({'cf_cf4': 'foo'}, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset({'cf_cf3__n': ['foo']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__ic': ['foo']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__nic': ['foo']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__isw': ['foo']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__nisw': ['foo']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__iew': ['bar']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__niew': ['bar']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__ie': ['FOO']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf3__nie': ['FOO']}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_filter_text_loose(self):
|
||||||
|
self.assertEqual(self.filterset({'cf_cf4': ['foo']}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_filter_date(self):
|
def test_filter_date(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf5': '2016-06-26'}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf5': ['2016-06-26', '2016-06-27']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf5__n': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf5__gt': ['2016-06-27']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf5__gte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf5__lt': ['2016-06-27']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf5__lte': ['2016-06-27']}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
def test_filter_url(self):
|
def test_filter_url_strict(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf6': 'http://foo.example.com/'}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf6': ['http://a.example.com', 'http://b.example.com']}, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset({'cf_cf7': 'example.com'}, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset({'cf_cf6__n': ['http://b.example.com']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__ic': ['b']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__nic': ['b']}, self.queryset).qs.count(), 2)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__isw': ['http://']}, self.queryset).qs.count(), 3)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__nisw': ['http://']}, self.queryset).qs.count(), 0)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__iew': ['.com']}, self.queryset).qs.count(), 3)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__niew': ['.com']}, self.queryset).qs.count(), 0)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__ie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 1)
|
||||||
|
self.assertEqual(self.filterset({'cf_cf6__nie': ['HTTP://A.EXAMPLE.COM']}, self.queryset).qs.count(), 2)
|
||||||
|
|
||||||
|
def test_filter_url_loose(self):
|
||||||
|
self.assertEqual(self.filterset({'cf_cf7': ['example.com']}, self.queryset).qs.count(), 3)
|
||||||
|
|
||||||
def test_filter_select(self):
|
def test_filter_select(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf8': 'Foo'}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf8': ['Foo', 'Bar']}, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset({'cf_cf8': 'Bar'}, self.queryset).qs.count(), 1)
|
|
||||||
self.assertEqual(self.filterset({'cf_cf8': 'Baz'}, self.queryset).qs.count(), 0)
|
|
||||||
|
|
||||||
def test_filter_multiselect(self):
|
def test_filter_multiselect(self):
|
||||||
self.assertEqual(self.filterset({'cf_cf9': 'A'}, self.queryset).qs.count(), 1)
|
self.assertEqual(self.filterset({'cf_cf9': ['A', 'B']}, self.queryset).qs.count(), 2)
|
||||||
self.assertEqual(self.filterset({'cf_cf9': 'B'}, self.queryset).qs.count(), 2)
|
self.assertEqual(self.filterset({'cf_cf9': ['X']}, self.queryset).qs.count(), 3)
|
||||||
self.assertEqual(self.filterset({'cf_cf9': 'C'}, self.queryset).qs.count(), 0)
|
|
||||||
|
@ -2,19 +2,19 @@ import django_filters
|
|||||||
from copy import deepcopy
|
from copy import deepcopy
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django_filters.exceptions import FieldLookupError
|
||||||
from django_filters.utils import get_model_field, resolve_field
|
from django_filters.utils import get_model_field, resolve_field
|
||||||
|
|
||||||
from dcim.forms import MACAddressField
|
|
||||||
from extras.choices import CustomFieldFilterLogicChoices
|
from extras.choices import CustomFieldFilterLogicChoices
|
||||||
from extras.filters import CustomFieldFilter, TagFilter
|
from extras.filters import TagFilter
|
||||||
from extras.models import CustomField
|
from extras.models import CustomField
|
||||||
from utilities.constants import (
|
from utilities.constants import (
|
||||||
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
|
||||||
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
FILTER_NUMERIC_BASED_LOOKUP_MAP
|
||||||
)
|
)
|
||||||
|
from utilities.forms import MACAddressField
|
||||||
from utilities import filters
|
from utilities import filters
|
||||||
|
|
||||||
|
|
||||||
__all__ = (
|
__all__ = (
|
||||||
'BaseFilterSet',
|
'BaseFilterSet',
|
||||||
'ChangeLoggedModelFilterSet',
|
'ChangeLoggedModelFilterSet',
|
||||||
@ -84,6 +84,7 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||||||
def _get_filter_lookup_dict(existing_filter):
|
def _get_filter_lookup_dict(existing_filter):
|
||||||
# Choose the lookup expression map based on the filter type
|
# Choose the lookup expression map based on the filter type
|
||||||
if isinstance(existing_filter, (
|
if isinstance(existing_filter, (
|
||||||
|
django_filters.NumberFilter,
|
||||||
filters.MultiValueDateFilter,
|
filters.MultiValueDateFilter,
|
||||||
filters.MultiValueDateTimeFilter,
|
filters.MultiValueDateTimeFilter,
|
||||||
filters.MultiValueNumberFilter,
|
filters.MultiValueNumberFilter,
|
||||||
@ -116,29 +117,18 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
def get_filters(cls):
|
def get_additional_lookups(cls, existing_filter_name, existing_filter):
|
||||||
"""
|
|
||||||
Override filter generation to support dynamic lookup expressions for certain filter types.
|
|
||||||
|
|
||||||
For specific filter types, new filters are created based on defined lookup expressions in
|
|
||||||
the form `<field_name>__<lookup_expr>`
|
|
||||||
"""
|
|
||||||
filters = super().get_filters()
|
|
||||||
|
|
||||||
new_filters = {}
|
new_filters = {}
|
||||||
for existing_filter_name, existing_filter in filters.items():
|
|
||||||
# Loop over existing filters to extract metadata by which to create new filters
|
|
||||||
|
|
||||||
# If the filter makes use of a custom filter method or lookup expression skip it
|
# Skip nonstandard lookup expressions
|
||||||
# as we cannot sanely handle these cases in a generic mannor
|
|
||||||
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
|
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'in']:
|
||||||
continue
|
return {}
|
||||||
|
|
||||||
# Choose the lookup expression map based on the filter type
|
# Choose the lookup expression map based on the filter type
|
||||||
lookup_map = cls._get_filter_lookup_dict(existing_filter)
|
lookup_map = cls._get_filter_lookup_dict(existing_filter)
|
||||||
if lookup_map is None:
|
if lookup_map is None:
|
||||||
# Do not augment this filter type with more lookup expressions
|
# Do not augment this filter type with more lookup expressions
|
||||||
continue
|
return {}
|
||||||
|
|
||||||
# Get properties of the existing filter for later use
|
# Get properties of the existing filter for later use
|
||||||
field_name = existing_filter.field_name
|
field_name = existing_filter.field_name
|
||||||
@ -146,11 +136,11 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||||||
|
|
||||||
# Create new filters for each lookup expression in the map
|
# Create new filters for each lookup expression in the map
|
||||||
for lookup_name, lookup_expr in lookup_map.items():
|
for lookup_name, lookup_expr in lookup_map.items():
|
||||||
new_filter_name = '{}__{}'.format(existing_filter_name, lookup_name)
|
new_filter_name = f'{existing_filter_name}__{lookup_name}'
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if existing_filter_name in cls.declared_filters:
|
if existing_filter_name in cls.declared_filters:
|
||||||
# The filter field has been explicity defined on the filterset class so we must manually
|
# The filter field has been explicitly defined on the filterset class so we must manually
|
||||||
# create the new filter with the same type because there is no guarantee the defined type
|
# 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
|
# is the same as the default type for the field
|
||||||
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
|
resolve_field(field, lookup_expr) # Will raise FieldLookupError if the lookup is invalid
|
||||||
@ -162,11 +152,15 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||||||
distinct=existing_filter.distinct,
|
distinct=existing_filter.distinct,
|
||||||
**existing_filter.extra
|
**existing_filter.extra
|
||||||
)
|
)
|
||||||
|
elif hasattr(existing_filter, 'custom_field'):
|
||||||
|
# Filter is for a custom field
|
||||||
|
custom_field = existing_filter.custom_field
|
||||||
|
new_filter = custom_field.to_filter(lookup_expr=lookup_expr)
|
||||||
else:
|
else:
|
||||||
# The filter field is listed in Meta.fields so we can safely rely on default behaviour
|
# The filter field is listed in Meta.fields so we can safely rely on default behaviour
|
||||||
# Will raise FieldLookupError if the lookup is invalid
|
# Will raise FieldLookupError if the lookup is invalid
|
||||||
new_filter = cls.filter_for_field(field, field_name, lookup_expr)
|
new_filter = cls.filter_for_field(field, field_name, lookup_expr)
|
||||||
except django_filters.exceptions.FieldLookupError:
|
except FieldLookupError:
|
||||||
# The filter could not be created because the lookup expression is not supported on the field
|
# The filter could not be created because the lookup expression is not supported on the field
|
||||||
continue
|
continue
|
||||||
|
|
||||||
@ -177,7 +171,24 @@ class BaseFilterSet(django_filters.FilterSet):
|
|||||||
|
|
||||||
new_filters[new_filter_name] = new_filter
|
new_filters[new_filter_name] = new_filter
|
||||||
|
|
||||||
filters.update(new_filters)
|
return new_filters
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_filters(cls):
|
||||||
|
"""
|
||||||
|
Override filter generation to support dynamic lookup expressions for certain filter types.
|
||||||
|
|
||||||
|
For specific filter types, new filters are created based on defined lookup expressions in
|
||||||
|
the form `<field_name>__<lookup_expr>`
|
||||||
|
"""
|
||||||
|
filters = super().get_filters()
|
||||||
|
|
||||||
|
additional_filters = {}
|
||||||
|
for existing_filter_name, existing_filter in filters.items():
|
||||||
|
additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter))
|
||||||
|
|
||||||
|
filters.update(additional_filters)
|
||||||
|
|
||||||
return filters
|
return filters
|
||||||
|
|
||||||
|
|
||||||
@ -213,8 +224,19 @@ class PrimaryModelFilterSet(ChangeLoggedModelFilterSet):
|
|||||||
).exclude(
|
).exclude(
|
||||||
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
|
||||||
)
|
)
|
||||||
for cf in custom_fields:
|
|
||||||
self.filters['cf_{}'.format(cf.name)] = CustomFieldFilter(field_name=cf.name, custom_field=cf)
|
custom_field_filters = {}
|
||||||
|
for custom_field in custom_fields:
|
||||||
|
filter_name = f'cf_{custom_field.name}'
|
||||||
|
filter_instance = custom_field.to_filter()
|
||||||
|
if filter_instance:
|
||||||
|
custom_field_filters[filter_name] = filter_instance
|
||||||
|
|
||||||
|
# Add relevant additional lookups
|
||||||
|
additional_lookups = self.get_additional_lookups(filter_name, filter_instance)
|
||||||
|
custom_field_filters.update(additional_lookups)
|
||||||
|
|
||||||
|
self.filters.update(custom_field_filters)
|
||||||
|
|
||||||
|
|
||||||
class OrganizationalModelFilterSet(PrimaryModelFilterSet):
|
class OrganizationalModelFilterSet(PrimaryModelFilterSet):
|
||||||
|
@ -3,7 +3,7 @@ from django import forms
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
from django_filters.constants import EMPTY_VALUES
|
from django_filters.constants import EMPTY_VALUES
|
||||||
|
|
||||||
from dcim.forms import MACAddressField
|
from utilities.forms import MACAddressField
|
||||||
|
|
||||||
|
|
||||||
def multivalue_field_factory(field_class):
|
def multivalue_field_factory(field_class):
|
||||||
|
@ -2,6 +2,7 @@ import csv
|
|||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
from io import StringIO
|
from io import StringIO
|
||||||
|
from netaddr import AddrFormatError, EUI
|
||||||
|
|
||||||
import django_filters
|
import django_filters
|
||||||
from django import forms
|
from django import forms
|
||||||
@ -38,6 +39,7 @@ __all__ = (
|
|||||||
'ExpandableNameField',
|
'ExpandableNameField',
|
||||||
'JSONField',
|
'JSONField',
|
||||||
'LaxURLField',
|
'LaxURLField',
|
||||||
|
'MACAddressField',
|
||||||
'SlugField',
|
'SlugField',
|
||||||
'TagFilterField',
|
'TagFilterField',
|
||||||
)
|
)
|
||||||
@ -129,6 +131,28 @@ class JSONField(_JSONField):
|
|||||||
return json.dumps(value, sort_keys=True, indent=4)
|
return json.dumps(value, sort_keys=True, indent=4)
|
||||||
|
|
||||||
|
|
||||||
|
class MACAddressField(forms.Field):
|
||||||
|
widget = forms.CharField
|
||||||
|
default_error_messages = {
|
||||||
|
'invalid': 'MAC address must be in EUI-48 format',
|
||||||
|
}
|
||||||
|
|
||||||
|
def to_python(self, value):
|
||||||
|
value = super().to_python(value)
|
||||||
|
|
||||||
|
# Validate MAC address format
|
||||||
|
try:
|
||||||
|
value = EUI(value.strip())
|
||||||
|
except AddrFormatError:
|
||||||
|
raise forms.ValidationError(self.error_messages['invalid'], code='invalid')
|
||||||
|
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
#
|
||||||
|
# Content type fields
|
||||||
|
#
|
||||||
|
|
||||||
class ContentTypeChoiceMixin:
|
class ContentTypeChoiceMixin:
|
||||||
|
|
||||||
def __init__(self, queryset, *args, **kwargs):
|
def __init__(self, queryset, *args, **kwargs):
|
||||||
|
Loading…
Reference in New Issue
Block a user