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 .constants import *
from .models import *
from django.db.models import JSONField
__all__ = (
'CableFilterSet',
'CabledObjectFilterSet',
@ -1187,6 +1187,7 @@ class DeviceFilterSet(
'inventory_item_count',
)
def search(self, queryset, name, value):
if not value.strip():
return queryset

View File

@ -1,5 +1,8 @@
import django_filters
from copy import deepcopy
import strawberry
import strawberry_django
from django.contrib.contenttypes.models import ContentType
from django.db import models
from django.db.models import Q
@ -10,20 +13,24 @@ from django.utils.translation import gettext as _
from core.choices import ObjectChangeActionChoices
from core.models import ObjectChange
from extras.choices import CustomFieldFilterLogicChoices
from dcim.models import Device
from extras.filters import TagFilter
from extras.models import CustomField, SavedFilter
from utilities.constants import (
FILTER_CHAR_BASED_LOOKUP_MAP, FILTER_NEGATION_LOOKUP_MAP, FILTER_TREENODE_NEGATION_LOOKUP_MAP,
FILTER_NUMERIC_BASED_LOOKUP_MAP
FILTER_CHAR_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 import filters
__all__ = (
'BaseFilterSet',
'ChangeLoggedModelFilterSet',
'NetBoxModelFilterSet',
'OrganizationalModelFilterSet',
"BaseFilterSet",
"ChangeLoggedModelFilterSet",
"NetBoxModelFilterSet",
"OrganizationalModelFilterSet",
)
@ -31,58 +38,48 @@ __all__ = (
# FilterSets
#
class BaseFilterSet(django_filters.FilterSet):
"""
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({
models.AutoField: {
'filter_class': filters.MultiValueNumberFilter
},
models.CharField: {
'filter_class': filters.MultiValueCharFilter
},
models.DateField: {
'filter_class': filters.MultiValueDateFilter
},
models.DateTimeField: {
'filter_class': filters.MultiValueDateTimeFilter
},
models.DecimalField: {
'filter_class': filters.MultiValueDecimalFilter
},
models.EmailField: {
'filter_class': filters.MultiValueCharFilter
},
models.FloatField: {
'filter_class': filters.MultiValueNumberFilter
},
models.IntegerField: {
'filter_class': filters.MultiValueNumberFilter
},
models.PositiveIntegerField: {
'filter_class': filters.MultiValueNumberFilter
},
models.PositiveSmallIntegerField: {
'filter_class': filters.MultiValueNumberFilter
},
models.SlugField: {
'filter_class': filters.MultiValueCharFilter
},
models.SmallIntegerField: {
'filter_class': filters.MultiValueNumberFilter
},
models.TimeField: {
'filter_class': filters.MultiValueTimeFilter
},
models.URLField: {
'filter_class': filters.MultiValueCharFilter
},
MACAddressField: {
'filter_class': filters.MultiValueMACAddressFilter
},
})
FILTER_DEFAULTS = deepcopy(
django_filters.filterset.FILTER_FOR_DBFIELD_DEFAULTS
)
FILTER_DEFAULTS.update(
{
models.AutoField: {"filter_class": filters.MultiValueNumberFilter},
models.CharField: {"filter_class": filters.MultiValueCharFilter},
models.DateField: {"filter_class": filters.MultiValueDateFilter},
models.DateTimeField: {
"filter_class": filters.MultiValueDateTimeFilter
},
models.DecimalField: {
"filter_class": filters.MultiValueDecimalFilter
},
models.EmailField: {"filter_class": filters.MultiValueCharFilter},
models.FloatField: {"filter_class": filters.MultiValueNumberFilter},
models.IntegerField: {
"filter_class": filters.MultiValueNumberFilter
},
models.PositiveIntegerField: {
"filter_class": filters.MultiValueNumberFilter
},
models.PositiveSmallIntegerField: {
"filter_class": filters.MultiValueNumberFilter
},
models.SlugField: {"filter_class": filters.MultiValueCharFilter},
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):
# 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()
# 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
saved_filters = SavedFilter.objects.filter(
Q(slug__in=data.pop('filter', [])) |
Q(pk__in=data.pop('filter_id', []))
Q(slug__in=data.pop("filter", []))
| Q(pk__in=data.pop("filter_id", []))
)
for sf in saved_filters:
for key, value in sf.parameters.items():
@ -113,36 +110,45 @@ class BaseFilterSet(django_filters.FilterSet):
@staticmethod
def _get_filter_lookup_dict(existing_filter):
# Choose the lookup expression map based on the filter type
if isinstance(existing_filter, (
django_filters.NumberFilter,
filters.MultiValueDateFilter,
filters.MultiValueDateTimeFilter,
filters.MultiValueNumberFilter,
filters.MultiValueDecimalFilter,
filters.MultiValueTimeFilter
)):
if isinstance(
existing_filter,
(
django_filters.NumberFilter,
filters.MultiValueDateFilter,
filters.MultiValueDateTimeFilter,
filters.MultiValueNumberFilter,
filters.MultiValueDecimalFilter,
filters.MultiValueTimeFilter,
),
):
return FILTER_NUMERIC_BASED_LOOKUP_MAP
elif isinstance(existing_filter, (
filters.TreeNodeMultipleChoiceFilter,
)):
elif isinstance(
existing_filter, (filters.TreeNodeMultipleChoiceFilter,)
):
# TreeNodeMultipleChoiceFilter only support negation but must maintain the `in` lookup expression
return FILTER_TREENODE_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, (
django_filters.ModelChoiceFilter,
django_filters.ModelMultipleChoiceFilter,
TagFilter
)):
elif isinstance(
existing_filter,
(
django_filters.ModelChoiceFilter,
django_filters.ModelMultipleChoiceFilter,
TagFilter,
),
):
# These filter types support only negation
return FILTER_NEGATION_LOOKUP_MAP
elif isinstance(existing_filter, (
django_filters.filters.CharFilter,
django_filters.MultipleChoiceFilter,
filters.MultiValueCharFilter,
filters.MultiValueMACAddressFilter
)):
elif isinstance(
existing_filter,
(
django_filters.filters.CharFilter,
django_filters.MultipleChoiceFilter,
filters.MultiValueCharFilter,
filters.MultiValueMACAddressFilter,
),
):
return FILTER_CHAR_BASED_LOOKUP_MAP
return None
@ -156,7 +162,10 @@ class BaseFilterSet(django_filters.FilterSet):
return {}
# 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 {}
# 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
field_name = existing_filter.field_name
# print("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
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)
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
# 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
resolve_field(
field, lookup_expr
) # Will raise FieldLookupError if the lookup is invalid
filter_cls = type(existing_filter)
if lookup_expr == 'empty':
if lookup_expr == "empty":
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)
new_filter = filter_cls(
field_name=field_name,
@ -191,21 +206,23 @@ class BaseFilterSet(django_filters.FilterSet):
label=existing_filter.label,
exclude=existing_filter.exclude,
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
custom_field = existing_filter.custom_field
new_filter = custom_field.to_filter(lookup_expr=lookup_expr)
else:
# The filter field is listed in Meta.fields so we can safely rely on default behaviour
# 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:
# The filter could not be created because the lookup expression is not supported on the field
continue
if lookup_name.startswith('n'):
if lookup_name.startswith("n"):
# 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
new_filter.exclude = not existing_filter.exclude
@ -226,7 +243,11 @@ class BaseFilterSet(django_filters.FilterSet):
additional_filters = {}
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)
@ -234,8 +255,7 @@ class BaseFilterSet(django_filters.FilterSet):
@classmethod
def filter_for_lookup(cls, field, lookup_type):
if lookup_type == 'empty':
if lookup_type == "empty":
return django_filters.BooleanFilter, {}
return super().filter_for_lookup(field, lookup_type)
@ -245,31 +265,35 @@ class ChangeLoggedModelFilterSet(BaseFilterSet):
"""
Base FilterSet for ChangeLoggedModel classes.
"""
created = filters.MultiValueDateTimeFilter()
last_updated = filters.MultiValueDateTimeFilter()
created_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'
)
created_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):
content_type = ContentType.objects.get_for_model(self.Meta.model)
action = {
'created_by_request': Q(action=ObjectChangeActionChoices.ACTION_CREATE),
'updated_by_request': Q(action=ObjectChangeActionChoices.ACTION_UPDATE),
'modified_by_request': Q(action__in=[ObjectChangeActionChoices.ACTION_CREATE, ObjectChangeActionChoices.ACTION_UPDATE]),
"created_by_request": Q(
action=ObjectChangeActionChoices.ACTION_CREATE
),
"updated_by_request": Q(
action=ObjectChangeActionChoices.ACTION_UPDATE
),
"modified_by_request": Q(
action__in=[
ObjectChangeActionChoices.ACTION_CREATE,
ObjectChangeActionChoices.ACTION_UPDATE,
]
),
}.get(name)
request_id = value
pks = ObjectChange.objects.filter(
action,
changed_object_type=content_type,
request_id=request_id,
).values_list('changed_object_id', flat=True)
).values_list("changed_object_id", flat=True)
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.
"""
q = django_filters.CharFilter(
method='search',
label=_('Search'),
method="search",
label=_("Search"),
)
tag = TagFilter()
@ -289,19 +314,19 @@ class NetBoxModelFilterSet(ChangeLoggedModelFilterSet):
# Dynamically add a Filter for each CustomField applicable to the parent model
custom_fields = CustomField.objects.filter(
object_types=ContentType.objects.get_for_model(self._meta.model)
).exclude(
filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED
)
).exclude(filter_logic=CustomFieldFilterLogicChoices.FILTER_DISABLED)
custom_field_filters = {}
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()
if filter_instance:
custom_field_filters[filter_name] = filter_instance
# 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)
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
"""
def search(self, queryset, name, value):
if not value.strip():
return queryset
return queryset.filter(
models.Q(name__icontains=value) |
models.Q(slug__icontains=value) |
models.Q(description__icontains=value)
models.Q(name__icontains=value)
| models.Q(slug__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 typing import List
import django_filters
import strawberry
import strawberry_django
from django.core.exceptions import FieldDoesNotExist, ValidationError
from django.contrib.contenttypes.models import ContentType
from django.core.exceptions import FieldDoesNotExist
from strawberry import auto
from extras.choices import CustomFieldFilterLogicChoices, CustomFieldTypeChoices
from extras.models import CustomField
from ipam.fields import ASNField
from netbox.graphql.scalars import BigInt
from utilities.fields import ColorField, CounterCacheField
@ -138,7 +145,7 @@ def autotype_decorator(filterset):
class ExampleFilter(BaseFilterMixin):
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
`fields = [created, ]` the dataclass would be:
@ -159,9 +166,70 @@ def autotype_decorator(filterset):
setattr(cls, filter_name, partialmethod(filter_by_filterset, key=fieldname))
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
fields = filterset.get_fields()
model = filterset._meta.model
# Add a Filter for each field on the model itself
for fieldname in fields.keys():
should_create_function = False
attr_type = auto