Skip to content
Permalink

Comparing changes

Choose two branches to see what’s changed or to start a new pull request. If you need to, you can also or learn more about diff comparisons.

Open a pull request

Create a new pull request by comparing changes across two branches. If you need to, you can also . Learn more about diff comparisons here.
base repository: carltongibson/django-filter
Failed to load repositories. Confirm that selected base ref is valid, then try again.
Loading
base: 24.2
Choose a base ref
...
head repository: carltongibson/django-filter
Failed to load repositories. Confirm that selected head ref is valid, then try again.
Loading
compare: 24.3
Choose a head ref
  • 5 commits
  • 10 files changed
  • 6 contributors

Commits on Jun 14, 2024

  1. Added testing against Django 5.1. (#1670)

    carltongibson authored Jun 14, 2024
    Copy the full SHA
    671deff View commit details

Commits on Jun 17, 2024

  1. Translated using Weblate (Arabic) (#1671)

    Currently translated at 100.0% (42 of 42 strings)
    
    Translation: django-filter/django-filter
    Translate-URL: https://hosted.weblate.org/projects/django-filter/django-filter/ar/
    
    Co-authored-by: Ahmed Nehad <nmkao333@gmail.com>
    weblate and Ahmed-Nehad authored Jun 17, 2024
    Copy the full SHA
    c8889c4 View commit details

Commits on Jul 19, 2024

  1. Add unknown_field_behavior feature (#1675)

    loeeess authored Jul 19, 2024
    Copy the full SHA
    376d443 View commit details

Commits on Aug 2, 2024

  1. Allowed using dictionaries for grouped choices (#1668)

    Co-authored-by: Carlton Gibson <carlton.gibson@noumenal.es>
    saevarom and carltongibson authored Aug 2, 2024
    Copy the full SHA
    23be051 View commit details
  2. Bumped version and changes for 24.3 release. (#1679)

    carltongibson authored Aug 2, 2024
    Copy the full SHA
    2ec1bae View commit details
Showing with 204 additions and 16 deletions.
  1. +14 −0 CHANGES.rst
  2. +2 −2 django_filters/__init__.py
  3. +14 −0 django_filters/filters.py
  4. +38 −7 django_filters/filterset.py
  5. +8 −7 django_filters/locale/ar/LC_MESSAGES/django.po
  6. +29 −0 docs/ref/filterset.txt
  7. +1 −0 pyproject.toml
  8. +18 −0 tests/test_filters.py
  9. +78 −0 tests/test_filterset.py
  10. +2 −0 tox.ini
14 changes: 14 additions & 0 deletions CHANGES.rst
Original file line number Diff line number Diff line change
@@ -1,3 +1,17 @@
Version 24.3 (2024-08-02)
-------------------------

* Adds official support for Django 5.1.

* Allow using dictionaries for grouped choices on Django 5.0+.

Thanks to Sævar Öfjörð Magnússon.

* Adds ``unknown_field_behavior`` FilterSet option to allowing warning and
ignore behaviours for unknown field types during FilterSet generation.

Thanks to Loes.

Version 24.2 (2024-03-27)
-------------------------

4 changes: 2 additions & 2 deletions django_filters/__init__.py
Original file line number Diff line number Diff line change
@@ -2,15 +2,15 @@
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.
if importlib_util.find_spec("rest_framework"):
from . import rest_framework
del importlib_util

__version__ = "24.2"
__version__ = "24.3"


def parse_version(version):
14 changes: 14 additions & 0 deletions django_filters/filters.py
Original file line number Diff line number Diff line change
@@ -30,6 +30,14 @@
)
from .utils import get_model_field, label_for_filter

try:
from django.utils.choices import normalize_choices
except ImportError:
DJANGO_50 = False
else:
DJANGO_50 = True


__all__ = [
"AllValuesFilter",
"AllValuesMultipleFilter",
@@ -479,6 +487,12 @@ def __init__(self, choices=None, filters=None, *args, **kwargs):
if filters is not None:
self.filters = filters

if isinstance(self.choices, dict):
if DJANGO_50:
self.choices = normalize_choices(self.choices)
else:
raise ValueError("Django 5.0 or later is required for dict choices")

all_choices = list(
chain.from_iterable(
[subchoice[0] for subchoice in choice[1]]
45 changes: 38 additions & 7 deletions django_filters/filterset.py
Original file line number Diff line number Diff line change
@@ -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)

15 changes: 8 additions & 7 deletions django_filters/locale/ar/LC_MESSAGES/django.po
Original file line number Diff line number Diff line change
@@ -9,16 +9,17 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2023-02-10 11:07+0000\n"
"PO-Revision-Date: 2020-03-24 00:48+0100\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
"Language: \n"
"PO-Revision-Date: 2024-06-16 14:09+0000\n"
"Last-Translator: Ahmed Nehad <nmkao333@gmail.com>\n"
"Language-Team: Arabic <https://hosted.weblate.org/projects/django-filter/"
"django-filter/ar/>\n"
"Language: ar\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=6; plural=n==0 ? 0 : n==1 ? 1 : n==2 ? 2 : n%100>=3 "
"&& n%100<=10 ? 3 : n%100>=11 && n%100<=99 ? 4 : 5;\n"
"X-Generator: Gtranslator 2.91.7\n"
"&& n%100<=10 ? 3 : n%100>=11 ? 4 : 5;\n"
"X-Generator: Weblate 5.6-dev\n"

#: conf.py:16
msgid "date"
@@ -90,7 +91,7 @@ msgstr "في النطاق"

#: conf.py:39
msgid "is null"
msgstr ""
msgstr "ليس موجود"

#: conf.py:40 conf.py:41
msgid "matches regex"
29 changes: 29 additions & 0 deletions docs/ref/filterset.txt
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ Meta options
- :ref:`exclude <exclude>`
- :ref:`form <form>`
- :ref:`filter_overrides <filter_overrides>`
- :ref:`unknown_field_behavior <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
--------------------------------

1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@ classifiers = [
"Framework :: Django",
"Framework :: Django :: 4.2",
"Framework :: Django :: 5.0",
"Framework :: Django :: 5.1",
"Programming Language :: Python",
"Programming Language :: Python :: 3",
"Programming Language :: Python :: 3.8",
18 changes: 18 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import inspect
import unittest
from collections import OrderedDict
from datetime import date, datetime, time, timedelta
from unittest import mock

import django
from django import forms
from django.test import TestCase, override_settings
from django.utils import translation
@@ -1105,6 +1107,22 @@ def test_choices_with_optgroups_dont_mistmatch(self):
choices=[("group", ("a", "a")), ("b", "b")], filters={"a": None, "b": None}
)

@unittest.skipUnless(django.VERSION >= (5, 0), "Django 5.0 introduced new dictionary choices option")
def test_grouped_choices_as_dictionary(self):
DateRangeFilter(
choices={"group": {"a": "a", "b": "b"}}, filters={"a": None, "b": None}
)

@unittest.skipUnless(django.VERSION <= (4, 2), "Django 5.0 introduced new dictionary choices option")
def test_grouped_choices_error(self):
with self.assertRaisesMessage(
ValueError,
"Django 5.0 or later is required for dict choices"
):
DateRangeFilter(
choices={"group": {"a": "a", "b": "b"}}, filters={"a": None, "b": None}
)

def test_filtering_for_this_year(self):
qs = mock.Mock(spec=["filter"])
with mock.patch("django_filters.filters.now") as mock_now:
78 changes: 78 additions & 0 deletions tests/test_filterset.py
Original file line number Diff line number Diff line change
@@ -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")
2 changes: 2 additions & 0 deletions tox.ini
Original file line number Diff line number Diff line change
@@ -2,6 +2,7 @@
envlist =
{py38, py39, py310, py311, py312}-django42,
{py310, py311, py312}-django50,
{py310, py311, py312}-django51,
{py310, py311, py312}-latest,
isort,lint,docs,warnings,
isolated_build = true
@@ -19,6 +20,7 @@ setenv =
deps =
django42: Django>=4.2rc1,<5.0
django50: Django>=5.0b1,<5.1
django51: Django>=5.1a1,<5.2
!latest: djangorestframework
latest: {[latest]deps}
-r requirements/test-ci.txt