wip: Filter by custom fields in graph ql

This commit is contained in:
Christoph Beberweil 2024-09-04 15:15:21 +02:00
parent 05daa16aed
commit 3de7457a36
3 changed files with 211 additions and 116 deletions

View File

@ -27,7 +27,7 @@ from wireless.models import WirelessLAN, WirelessLink
from .choices import * from .choices import *
from .constants import * from .constants import *
from .models import * from .models import *
from django.db.models import JSONField
__all__ = ( __all__ = (
'CableFilterSet', 'CableFilterSet',
'CabledObjectFilterSet', 'CabledObjectFilterSet',
@ -1187,6 +1187,7 @@ class DeviceFilterSet(
'inventory_item_count', 'inventory_item_count',
) )
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset

View File

@ -1,5 +1,8 @@
import django_filters import django_filters
from copy import deepcopy from copy import deepcopy
import strawberry
import strawberry_django
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.db.models import Q from django.db.models import Q
@ -10,20 +13,24 @@ from django.utils.translation import gettext as _
from core.choices import ObjectChangeActionChoices from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange from core.models import ObjectChange
from extras.choices import CustomFieldFilterLogicChoices from extras.choices import CustomFieldFilterLogicChoices
from dcim.models import Device
from extras.filters import TagFilter from extras.filters import TagFilter
from extras.models import CustomField, SavedFilter from extras.models import CustomField, SavedFilter
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_NUMERIC_BASED_LOOKUP_MAP FILTER_NEGATION_LOOKUP_MAP,
FILTER_TREENODE_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP,
) )
from utilities.forms.fields import MACAddressField from utilities.forms.fields import MACAddressField
from utilities import filters from utilities import filters
__all__ = ( __all__ = (
'BaseFilterSet', "BaseFilterSet",
'ChangeLoggedModelFilterSet', "ChangeLoggedModelFilterSet",
'NetBoxModelFilterSet', "NetBoxModelFilterSet",
'OrganizationalModelFilterSet', "OrganizationalModelFilterSet",
) )
@ -31,58 +38,48 @@ __all__ = (
# FilterSets # FilterSets
# #
class BaseFilterSet(django_filters.FilterSet): class BaseFilterSet(django_filters.FilterSet):
""" """
A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class. A base FilterSet which provides some enhanced functionality over django-filter2's FilterSet class.
""" """
FILTER_DEFAULTS = deepcopy(django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS)
FILTER_DEFAULTS.update({ FILTER_DEFAULTS = deepcopy(
models.AutoField: { django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS
'filter_class': filters.MultiValueNumberFilter )
}, FILTER_DEFAULTS.update(
models.CharField: { {
'filter_class': filters.MultiValueCharFilter models.AutoField: {"filter_class": filters.MultiValueNumberFilter},
}, models.CharField: {"filter_class": filters.MultiValueCharFilter},
models.DateField: { models.DateField: {"filter_class": filters.MultiValueDateFilter},
'filter_class': filters.MultiValueDateFilter models.DateTimeField: {
}, "filter_class": filters.MultiValueDateTimeFilter
models.DateTimeField: { },
'filter_class': filters.MultiValueDateTimeFilter models.DecimalField: {
}, "filter_class": filters.MultiValueDecimalFilter
models.DecimalField: { },
'filter_class': filters.MultiValueDecimalFilter models.EmailField: {"filter_class": filters.MultiValueCharFilter},
}, models.FloatField: {"filter_class": filters.MultiValueNumberFilter},
models.EmailField: { models.IntegerField: {
'filter_class': filters.MultiValueCharFilter "filter_class": filters.MultiValueNumberFilter
}, },
models.FloatField: { models.PositiveIntegerField: {
'filter_class': filters.MultiValueNumberFilter "filter_class": filters.MultiValueNumberFilter
}, },
models.IntegerField: { models.PositiveSmallIntegerField: {
'filter_class': filters.MultiValueNumberFilter "filter_class": filters.MultiValueNumberFilter
}, },
models.PositiveIntegerField: { models.SlugField: {"filter_class": filters.MultiValueCharFilter},
'filter_class': filters.MultiValueNumberFilter models.SmallIntegerField: {
}, "filter_class": filters.MultiValueNumberFilter
models.PositiveSmallIntegerField: { },
'filter_class': filters.MultiValueNumberFilter models.TimeField: {"filter_class": filters.MultiValueTimeFilter},
}, models.URLField: {"filter_class": filters.MultiValueCharFilter},
models.SlugField: { MACAddressField: {
'filter_class': filters.MultiValueCharFilter "filter_class": filters.MultiValueMACAddressFilter
}, },
models.SmallIntegerField: { }
'filter_class': filters.MultiValueNumberFilter )
},
models.TimeField: {
'filter_class': filters.MultiValueTimeFilter
},
models.URLField: {
'filter_class': filters.MultiValueCharFilter
},
MACAddressField: {
'filter_class': filters.MultiValueMACAddressFilter
},
})
def __init__(self, data=None, *args, **kwargs): def __init__(self, data=None, *args, **kwargs):
# bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready # bit of a hack for #9231 - extras.lookup.Empty is registered in apps.ready
@ -91,11 +88,11 @@ class BaseFilterSet(django_filters.FilterSet):
self.base_filters = self.__class__.get_filters() self.base_filters = self.__class__.get_filters()
# Apply any referenced SavedFilters # Apply any referenced SavedFilters
if data and ('filter' in data or 'filter_id' in data): if data and ("filter" in data or "filter_id" in data):
data = data.copy() # Get a mutable copy data = data.copy() # Get a mutable copy
saved_filters = SavedFilter.objects.filter( saved_filters = SavedFilter.objects.filter(
Q(slug__in=data.pop('filter', [])) | Q(slug__in=data.pop("filter", []))
Q(pk__in=data.pop('filter_id', [])) | Q(pk__in=data.pop("filter_id", []))
) )
for sf in saved_filters: for sf in saved_filters:
for key, value in sf.parameters.items(): for key, value in sf.parameters.items():
@ -113,36 +110,45 @@ class BaseFilterSet(django_filters.FilterSet):
@staticmethod @staticmethod
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(
django_filters.NumberFilter, existing_filter,
filters.MultiValueDateFilter, (
filters.MultiValueDateTimeFilter, django_filters.NumberFilter,
filters.MultiValueNumberFilter, filters.MultiValueDateFilter,
filters.MultiValueDecimalFilter, filters.MultiValueDateTimeFilter,
filters.MultiValueTimeFilter filters.MultiValueNumberFilter,
)): filters.MultiValueDecimalFilter,
filters.MultiValueTimeFilter,
),
):
return FILTER_NUMERIC_BASED_LOOKUP_MAP return FILTER_NUMERIC_BASED_LOOKUP_MAP
elif isinstance(existing_filter, ( elif isinstance(
filters.TreeNodeMultipleChoiceFilter, existing_filter, (filters.TreeNodeMultipleChoiceFilter,)
)): ):
# TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression # TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
return FILTER_TREENODE_NEGATION_LOOKUP_MAP return FILTER_TREENODE_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, ( elif isinstance(
django_filters.ModelChoiceFilter, existing_filter,
django_filters.ModelMultipleChoiceFilter, (
TagFilter django_filters.ModelChoiceFilter,
)): django_filters.ModelMultipleChoiceFilter,
TagFilter,
),
):
# These filter types support only negation # These filter types support only negation
return FILTER_NEGATION_LOOKUP_MAP return FILTER_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, ( elif isinstance(
django_filters.filters.CharFilter, existing_filter,
django_filters.MultipleChoiceFilter, (
filters.MultiValueCharFilter, django_filters.filters.CharFilter,
filters.MultiValueMACAddressFilter django_filters.MultipleChoiceFilter,
)): filters.MultiValueCharFilter,
filters.MultiValueMACAddressFilter,
),
):
return FILTER_CHAR_BASED_LOOKUP_MAP return FILTER_CHAR_BASED_LOOKUP_MAP
return None return None
@ -156,7 +162,10 @@ class BaseFilterSet(django_filters.FilterSet):
return {} return {}
# Skip nonstandard lookup expressions # Skip nonstandard lookup expressions
if existing_filter.method is not None or existing_filter.lookup_expr not in ['exact', 'iexact', 'in']: if (
existing_filter.method is not None
or existing_filter.lookup_expr not in ["exact", "iexact", "in"]
):
return {} return {}
# Choose the lookup expression map based on the filter type # Choose the lookup expression map based on the filter type
@ -167,11 +176,14 @@ class BaseFilterSet(django_filters.FilterSet):
# 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
# print("get_model_field", cls._meta.model, field_name)
field = get_model_field(cls._meta.model, field_name) field = get_model_field(cls._meta.model, field_name)
# 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 = f'{existing_filter_name}__{lookup_name}' new_filter_name = f"{existing_filter_name}__{lookup_name}"
existing_filter_extra = deepcopy(existing_filter.extra) existing_filter_extra = deepcopy(existing_filter.extra)
try: try:
@ -179,11 +191,14 @@ class BaseFilterSet(django_filters.FilterSet):
# The filter field has been explicitly 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
filter_cls = type(existing_filter) filter_cls = type(existing_filter)
if lookup_expr == 'empty': if lookup_expr == "empty":
filter_cls = django_filters.BooleanFilter filter_cls = django_filters.BooleanFilter
for param_to_remove in ('choices', 'null_value'): for param_to_remove in ("choices", "null_value"):
existing_filter_extra.pop(param_to_remove, None) existing_filter_extra.pop(param_to_remove, None)
new_filter = filter_cls( new_filter = filter_cls(
field_name=field_name, field_name=field_name,
@ -191,21 +206,23 @@ class BaseFilterSet(django_filters.FilterSet):
label=existing_filter.label, label=existing_filter.label,
exclude=existing_filter.exclude, exclude=existing_filter.exclude,
distinct=existing_filter.distinct, distinct=existing_filter.distinct,
**existing_filter_extra **existing_filter_extra,
) )
elif hasattr(existing_filter, 'custom_field'): elif hasattr(existing_filter, "custom_field"):
# Filter is for a custom field # Filter is for a custom field
custom_field = existing_filter.custom_field custom_field = existing_filter.custom_field
new_filter = custom_field.to_filter(lookup_expr=lookup_expr) 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 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
if lookup_name.startswith('n'): if lookup_name.startswith("n"):
# This is a negation filter which requires a queryset.exclude() clause # This is a negation filter which requires a queryset.exclude() clause
# Of course setting the negation of the existing filter's exclude attribute handles both cases # Of course setting the negation of the existing filter's exclude attribute handles both cases
new_filter.exclude = not existing_filter.exclude new_filter.exclude = not existing_filter.exclude
@ -226,7 +243,11 @@ class BaseFilterSet(django_filters.FilterSet):
additional_filters = {} additional_filters = {}
for existing_filter_name, existing_filter in filters.items(): for existing_filter_name, existing_filter in filters.items():
additional_filters.update(cls.get_additional_lookups(existing_filter_name, existing_filter)) additional_filters.update(
cls.get_additional_lookups(
existing_filter_name, existing_filter
)
)
filters.update(additional_filters) filters.update(additional_filters)
@ -234,8 +255,7 @@ class BaseFilterSet(django_filters.FilterSet):
@classmethod @classmethod
def filter_for_lookup(cls, field, lookup_type): def filter_for_lookup(cls, field, lookup_type):
if lookup_type == "empty":
if lookup_type == 'empty':
return django_filters.BooleanFilter, {} return django_filters.BooleanFilter, {}
return super().filter_for_lookup(field, lookup_type) return super().filter_for_lookup(field, lookup_type)
@ -245,31 +265,35 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
""" """
Base FilterSet for ChangeLoggedModel classes. Base FilterSet for ChangeLoggedModel classes.
""" """
created = filters.MultiValueDateTimeFilter() created = filters.MultiValueDateTimeFilter()
last_updated = filters.MultiValueDateTimeFilter() last_updated = filters.MultiValueDateTimeFilter()
created_by_request = django_filters.UUIDFilter( created_by_request = django_filters.UUIDFilter(method="filter_by_request")
method='filter_by_request' updated_by_request = django_filters.UUIDFilter(method="filter_by_request")
) modified_by_request = django_filters.UUIDFilter(method="filter_by_request")
updated_by_request = django_filters.UUIDFilter(
method='filter_by_request'
)
modified_by_request = django_filters.UUIDFilter(
method='filter_by_request'
)
def filter_by_request(self, queryset, name, value): def filter_by_request(self, queryset, name, value):
content_type = ContentType.objects.get_for_model(self.Meta.model) content_type = ContentType.objects.get_for_model(self.Meta.model)
action = { action = {
'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE), "created_by_request": Q(
'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE), action=ObjectChangeActionChoices.ACTION_CREATE
'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]), ),
"updated_by_request": Q(
action=ObjectChangeActionChoices.ACTION_UPDATE
),
"modified_by_request": Q(
action__in=[
ObjectChangeActionChoices.ACTION_CREATE,
ObjectChangeActionChoices.ACTION_UPDATE,
]
),
}.get(name) }.get(name)
request_id = value request_id = value
pks = ObjectChange.objects.filter( pks = ObjectChange.objects.filter(
action, action,
changed_object_type=content_type, changed_object_type=content_type,
request_id=request_id, request_id=request_id,
).values_list('changed_object_id', flat=True) ).values_list("changed_object_id", flat=True)
return queryset.filter(pk__in=pks) return queryset.filter(pk__in=pks)
@ -277,9 +301,10 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
""" """
Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models. Provides additional filtering functionality (e.g. tags, custom fields) for core NetBox models.
""" """
q = django_filters.CharFilter( q = django_filters.CharFilter(
method='search', method="search",
label=_('Search'), label=_("Search"),
) )
tag = TagFilter() tag = TagFilter()
@ -289,19 +314,19 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
# Dynamically add a Filter for each CustomField applicable to the parent model # Dynamically add a Filter for each CustomField applicable to the parent model
custom_fields = CustomField.objects.filter( custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self._meta.model) object_types=ContentType.objects.get_for_model(self._meta.model)
).exclude( ).exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
custom_field_filters = {} custom_field_filters = {}
for custom_field in custom_fields: for custom_field in custom_fields:
filter_name = f'cf_{custom_field.name}' filter_name = f"cf_{custom_field.name}"
filter_instance = custom_field.to_filter() filter_instance = custom_field.to_filter()
if filter_instance: if filter_instance:
custom_field_filters[filter_name] = filter_instance custom_field_filters[filter_name] = filter_instance
# Add relevant additional lookups # Add relevant additional lookups
additional_lookups = self.get_additional_lookups(filter_name, filter_instance) additional_lookups = self.get_additional_lookups(
filter_name, filter_instance
)
custom_field_filters.update(additional_lookups) custom_field_filters.update(additional_lookups)
self.filters.update(custom_field_filters) self.filters.update(custom_field_filters)
@ -317,11 +342,12 @@ class OrganizationalModelFilterSet(NetBoxModelFilterSet):
""" """
A base class for adding the search method to models which only expose the `name` and `slug` fields A base class for adding the search method to models which only expose the `name` and `slug` fields
""" """
def search(self, queryset, name, value): def search(self, queryset, name, value):
if not value.strip(): if not value.strip():
return queryset return queryset
return queryset.filter( return queryset.filter(
models.Q(name__icontains=value) | models.Q(name__icontains=value)
models.Q(slug__icontains=value) | | models.Q(slug__icontains=value)
models.Q(description__icontains=value) | models.Q(description__icontains=value)
) )

View File

@ -1,12 +1,19 @@
from _decimal import Decimal
from datetime import date, datetime
from functools import partialmethod from functools import partialmethod
from typing import List from typing import List
import django_filters import django_filters
import strawberry import strawberry
import strawberry_django import strawberry_django
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist from django.core.exceptions import FieldDoesNotExist
from strawberry import auto from strawberry import auto
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
from extras.models import CustomField
from ipam.fields import ASNField from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt from netbox.graphql.scalars import BigInt
from utilities.fields import ColorField, CounterCacheField from utilities.fields import ColorField, CounterCacheField
@ -138,7 +145,7 @@ def autotype_decorator(filterset):
class ExampleFilter(BaseFilterMixin): class ExampleFilter(BaseFilterMixin):
pass pass
The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields The Filter itself must be derived from BaseFilterMixin. For items listed in meta.fields
of the filterset, usually just a type specifier is generated, so for of the filterset, usually just a type specifier is generated, so for
`fields = [created, ]` the dataclass would be: `fields = [created, ]` the dataclass would be:
@ -159,9 +166,70 @@ def autotype_decorator(filterset):
setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname)) setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname))
def wrapper(cls): def wrapper(cls):
# Dynamically add a Filter for each CustomField applicable to the parent model
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(filterset._meta.model)
).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
## taken from netbox.filtersets.NetBoxModelFilterSet
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 = filterset.get_additional_lookups(filter_name, filter_instance)
custom_field_filters.update(additional_lookups)
filterset.declared_filters.update(custom_field_filters)
## mirror the call to create_attribute_and_function for the model fields, but for custom fields of the model.
attr_type = None
match custom_field.type:
case CustomFieldTypeChoices.TYPE_TEXT:
attr_type = str | None
case CustomFieldTypeChoices.TYPE_LONGTEXT:
attr_type = str | None
case CustomFieldTypeChoices.TYPE_DATE:
attr_type = date | None
case CustomFieldTypeChoices.TYPE_DATETIME:
attr_type = datetime | None
case CustomFieldTypeChoices.TYPE_INTEGER:
attr_type = int | None
case CustomFieldTypeChoices.TYPE_BOOLEAN:
attr_type = bool | None
case CustomFieldTypeChoices.TYPE_DECIMAL:
attr_type = Decimal | None
#### unclear which types to choose here
# case CustomFieldTypeChoices.TYPE_JSON:
# attr_type = dict | None
# case [CustomFieldTypeChoices.TYPE_MULTIOBJECT]:
# attr_type = str | None
# case [CustomFieldTypeChoices.TYPE_MULTISELECT]:
# attr_type = str | None
# case [CustomFieldTypeChoices.TYPE_SELECT]:
# attr_type = str | None
# case [CustomFieldTypeChoices.TYPE_OBJECT]:
# attr_type = str | None
if attr_type:
create_attribute_and_function(
cls,
fieldname=filter_name,
attr_type=attr_type,
should_create_function=True
)
cls.filterset = filterset cls.filterset = filterset
fields = filterset.get_fields() fields = filterset.get_fields()
model = filterset._meta.model model = filterset._meta.model
# Add a Filter for each field on the model itself
for fieldname in fields.keys(): for fieldname in fields.keys():
should_create_function = False should_create_function = False
attr_type = auto attr_type = auto