diff --git a/netbox/dcim/filtersets.py b/netbox/dcim/filtersets.py index 5a101e739b7..a43f727d19a 100644 --- a/netbox/dcim/filtersets.py +++ b/netbox/dcim/filtersets.py @@ -27,7 +27,7 @@ from .choices import * from .constants import * from .models import * - +from django.db.models import JSONField __all__ = ( 'CableFilterSet', 'CabledObjectFilterSet', @@ -1187,6 +1187,7 @@ class Meta: 'inventory_item_count', ) + def search(self, queryset, name, value): if not value.strip(): return queryset diff --git a/netbox/netbox/filtersets.py b/netbox/netbox/filtersets.py index 637a40bf114..8e143497e91 100644 --- a/netbox/netbox/filtersets.py +++ b/netbox/netbox/filtersets.py @@ -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 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 @@ # 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 @@ def __init__(self, data=None, *args, **kwargs): 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 @@ def __init__(self, data=None, *args, **kwargs): @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 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): 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 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): # 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 @@ def get_additional_lookups(cls, existing_filter_name, existing_filter): # 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 @@ 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'): + 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 @@ def get_filters(cls): 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 @@ def get_filters(cls): @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 @@ def __init__(self, *args, **kwargs): # 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) ) diff --git a/netbox/netbox/graphql/filter_mixins.py b/netbox/netbox/graphql/filter_mixins.py index 2044a1ddeeb..7bfc6833a64 100644 --- a/netbox/netbox/graphql/filter_mixins.py +++ b/netbox/netbox/graphql/filter_mixins.py @@ -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 create_attribute_and_function(cls, fieldname, attr_type, should_create_funct 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