From 376d4434b7c2186a566864e654c39a2926814e3f Mon Sep 17 00:00:00 2001 From: Loes Date: Fri, 19 Jul 2024 14:20:19 +0200 Subject: [PATCH] Add unknown_field_behavior feature (#1675) --- django_filters/__init__.py | 2 +- django_filters/filterset.py | 45 +++++++++++++++++---- docs/ref/filterset.txt | 29 ++++++++++++++ tests/test_filterset.py | 78 +++++++++++++++++++++++++++++++++++++ 4 files changed, 146 insertions(+), 8 deletions(-) diff --git a/django_filters/__init__.py b/django_filters/__init__.py index ca1f82d7..a9ad1c21 100644 --- a/django_filters/__init__.py +++ b/django_filters/__init__.py @@ -2,7 +2,7 @@ from importlib import util as importlib_util from .filters import * -from .filterset import FilterSet +from .filterset import FilterSet, UnknownFieldBehavior # We make the `rest_framework` module available without an additional import. # If DRF is not installed, no-op. diff --git a/django_filters/filterset.py b/django_filters/filterset.py index 6c7019e2..23a88b45 100644 --- a/django_filters/filterset.py +++ b/django_filters/filterset.py @@ -1,5 +1,7 @@ import copy +import warnings from collections import OrderedDict +from enum import Enum from django import forms from django.db import models @@ -43,6 +45,12 @@ def remote_queryset(field): return model._default_manager.complex_filter(limit_choices_to) +class UnknownFieldBehavior(Enum): + RAISE = "raise" + WARN = "warn" + IGNORE = "ignore" + + class FilterSetOptions: def __init__(self, options=None): self.model = getattr(options, "model", None) @@ -53,6 +61,13 @@ def __init__(self, options=None): self.form = getattr(options, "form", forms.Form) + behavior = getattr(options, "unknown_field_behavior", UnknownFieldBehavior.RAISE) + + if not isinstance(behavior, UnknownFieldBehavior): + raise ValueError(f"Invalid unknown_field_behavior: {behavior}") + + self.unknown_field_behavior = behavior + class FilterSetMetaclass(type): def __new__(cls, name, bases, attrs): @@ -338,9 +353,11 @@ def get_filters(cls): continue if field is not None: - filters[filter_name] = cls.filter_for_field( + filter_instance = cls.filter_for_field( field, field_name, lookup_expr ) + if filter_instance is not None: + filters[filter_name] = filter_instance # Allow Meta.fields to contain declared filters *only* when a list/tuple if isinstance(cls._meta.fields, (list, tuple)): @@ -357,6 +374,18 @@ def get_filters(cls): filters.update(cls.declared_filters) return filters + @classmethod + def handle_unrecognized_field(cls, field_name, message): + behavior = cls._meta.unknown_field_behavior + if behavior == UnknownFieldBehavior.RAISE: + raise AssertionError(message) + elif behavior == UnknownFieldBehavior.WARN: + warnings.warn(f"Unrecognized field type for '{field_name}'. Field will be ignored.") + elif behavior == UnknownFieldBehavior.IGNORE: + pass + else: + raise ValueError(f"Invalid unknown_field_behavior: {behavior}") + @classmethod def filter_for_field(cls, field, field_name, lookup_expr=None): if lookup_expr is None: @@ -371,12 +400,14 @@ def filter_for_field(cls, field, field_name, lookup_expr=None): filter_class, params = cls.filter_for_lookup(field, lookup_type) default.update(params) - assert filter_class is not None, ( - "%s resolved field '%s' with '%s' lookup to an unrecognized field " - "type %s. Try adding an override to 'Meta.filter_overrides'. See: " - "https://django-filter.readthedocs.io/en/main/ref/filterset.html" - "#customise-filter-generation-with-filter-overrides" - ) % (cls.__name__, field_name, lookup_expr, field.__class__.__name__) + if filter_class is None: + cls.handle_unrecognized_field(field_name, ( + "%s resolved field '%s' with '%s' lookup to an unrecognized field " + "type %s. Try adding an override to 'Meta.filter_overrides'. See: " + "https://django-filter.readthedocs.io/en/main/ref/filterset.html" + "#customise-filter-generation-with-filter-overrides" + ) % (cls.__name__, field_name, lookup_expr, field.__class__.__name__)) + return None return filter_class(**default) diff --git a/docs/ref/filterset.txt b/docs/ref/filterset.txt index ecc9e558..6265a4b0 100644 --- a/docs/ref/filterset.txt +++ b/docs/ref/filterset.txt @@ -12,6 +12,7 @@ Meta options - :ref:`exclude ` - :ref:`form
` - :ref:`filter_overrides ` +- :ref:`unknown_field_behavior ` .. _model: @@ -146,6 +147,34 @@ This is a map of model fields to filter classes with options:: }, } + +.. _unknown_field_behavior: + +Handling unknown fields with ``unknown_field_behavior`` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +The ``unknown_field_behavior`` option specifies how unknown fields are handled +in a ``FilterSet``. You can set this option using the values of the +``UnknownFieldBehavior`` enum: + +- ``UnknownFieldBehavior.RAISE``: Raise an assertion error (default) +- ``UnknownFieldBehavior.WARN``: Issue a warning and ignore the field +- ``UnknownFieldBehavior.IGNORE``: Silently ignore the field + +Note that both the ``WARN`` and ``IGNORE`` options do not include the unknown +field(s) in the list of filters. + +.. code-block:: python + + from django_filters import UnknownFieldBehavior + + class UserFilter(django_filters.FilterSet): + class Meta: + model = User + fields = ['username', 'last_login'] + unknown_field_behavior = UnknownFieldBehavior.WARN + + Overriding ``FilterSet`` methods -------------------------------- diff --git a/tests/test_filterset.py b/tests/test_filterset.py index 706eda25..77fafee5 100644 --- a/tests/test_filterset.py +++ b/tests/test_filterset.py @@ -1,4 +1,5 @@ import unittest +import warnings from unittest import mock from django.db import models @@ -23,6 +24,7 @@ from django_filters.filterset import ( FILTER_FOR_DBFIELD_DEFAULTS, FilterSet, + UnknownFieldBehavior, filterset_factory, ) from django_filters.widgets import BooleanWidget @@ -147,6 +149,7 @@ def test_field_that_is_subclassed(self): def test_unknown_field_type_error(self): f = NetworkSetting._meta.get_field("mask") + FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.RAISE with self.assertRaises(AssertionError) as excinfo: FilterSet.filter_for_field(f, "mask") @@ -157,6 +160,14 @@ def test_unknown_field_type_error(self): excinfo.exception.args[0], ) + def test_return_none(self): + f = NetworkSetting._meta.get_field("mask") + # Set unknown_field_behavior to 'ignore' to avoid raising exceptions + FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.IGNORE + result = FilterSet.filter_for_field(f, "mask") + + self.assertIsNone(result) + def test_symmetrical_selfref_m2m_field(self): f = Node._meta.get_field("adjacents") result = FilterSet.filter_for_field(f, "adjacents") @@ -202,6 +213,73 @@ def test_filter_overrides(self): pass +class HandleUnknownFieldTests(TestCase): + def setUp(self): + class NetworkSettingFilterSet(FilterSet): + class Meta: + model = NetworkSetting + fields = ["ip", "mask"] + # Initial field behavior set to 'ignore' to avoid crashing in setUp + unknown_field_behavior = UnknownFieldBehavior.IGNORE + + self.FilterSet = NetworkSettingFilterSet + + def test_raise_unknown_field_behavior(self): + self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.RAISE + + with self.assertRaises(AssertionError) as excinfo: + self.FilterSet.handle_unrecognized_field("mask", "test_message") + + self.assertIn( + "test_message", + excinfo.exception.args[0], + ) + + def test_unknown_field_warn_behavior(self): + self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.WARN + + with warnings.catch_warnings(record=True) as w: + warnings.simplefilter("always") + self.FilterSet.handle_unrecognized_field("mask", "test_message") + + self.assertIn( + "Unrecognized field type for 'mask'. " + "Field will be ignored.", + str(w[-1].message), + ) + + def test_unknown_field_ignore_behavior(self): + # No exception or warning should be raised + self.FilterSet._meta.unknown_field_behavior = UnknownFieldBehavior.IGNORE + self.FilterSet.handle_unrecognized_field("mask", "test_message") + + def test_unknown_field_invalid_initial_behavior(self): + # Creation of new custom FilterSet to set initial field behavior + with self.assertRaises(ValueError) as excinfo: + + class InvalidBehaviorFilterSet(FilterSet): + class Meta: + model = NetworkSetting + fields = ["ip", "mask"] + unknown_field_behavior = "invalid" + + self.assertIn( + "Invalid unknown_field_behavior: invalid", + str(excinfo.exception), + ) + + def test_unknown_field_invalid_changed_option_behavior(self): + self.FilterSet._meta.unknown_field_behavior = "invalid" + + with self.assertRaises(ValueError) as excinfo: + self.FilterSet.handle_unrecognized_field("mask", "test_message") + + self.assertIn( + "Invalid unknown_field_behavior: invalid", + str(excinfo.exception), + ) + + class FilterSetFilterForLookupTests(TestCase): def test_filter_for_ISNULL_lookup(self): f = Article._meta.get_field("author")