Skip to content

Commit 1d18843

Browse files
committed
fix #110 fix #111
1 parent 659168f commit 1d18843

File tree

7 files changed

+248
-48
lines changed

7 files changed

+248
-48
lines changed

doc/source/changelog.rst

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ Change Log
77
v2.2.0 (2025-03-23)
88
===================
99

10+
* Fixed `FilterSet override of filter_for_lookup disregards meta options. <https://github.com/bckohan/django-enum/issues/111>`_
11+
* Implemented `Add EnumFlagFilter to support has_any and has_all flag queries. <https://github.com/bckohan/django-enum/issues/110>`_
1012
* Fixed `Enum types that resolve to primitives of str or int but that do not inherit from those types can result in validation errors. <https://github.com/bckohan/django-enum/issues/109>`_
1113
* Implemented `Support checkboxes for FlagEnumField <https://github.com/bckohan/django-enum/issues/107>`_
1214
* Implemented `Support radio buttons for EnumChoiceField <https://github.com/bckohan/django-enum/issues/106>`_

src/django_enum/filters.py

Lines changed: 116 additions & 26 deletions
Original file line numberDiff line numberDiff line change
@@ -3,30 +3,29 @@
33
"""
44

55
import typing as t
6-
from enum import Enum
6+
from enum import Enum, Flag
77

88
from django.db.models import Field as ModelField
9-
from django_filters import (
10-
Filter,
11-
TypedChoiceFilter,
12-
TypedMultipleChoiceFilter,
13-
filterset,
14-
)
15-
16-
from django_enum.fields import EnumField
17-
from django_enum.forms import EnumChoiceField, EnumMultipleChoiceField
9+
from django.db.models import Q
10+
from django_filters import filterset
11+
from django_filters.filters import Filter, TypedChoiceFilter, TypedMultipleChoiceFilter
12+
from django_filters.utils import try_dbfield
13+
14+
from django_enum.fields import EnumField, FlagField
15+
from django_enum.forms import EnumChoiceField, EnumFlagField, EnumMultipleChoiceField
1816
from django_enum.utils import choices
1917

2018
__all__ = [
2119
"EnumFilter",
2220
"MultipleEnumFilter",
21+
"EnumFlagFilter",
2322
"FilterSet",
2423
]
2524

2625

2726
class EnumFilter(TypedChoiceFilter):
2827
"""
29-
Use this filter class instead of :ref:`ChoiceFilter <django-filter:choice-filter>`
28+
Use this filter class instead of :class:`~django_filters.filters.ChoiceFilter`
3029
to get filters to accept :class:`~enum.Enum` labels and symmetric properties.
3130
3231
For example if we have an enumeration field defined with the following
@@ -45,7 +44,7 @@ class Color(TextChoices):
4544
4645
color = EnumField(Color)
4746
48-
The default :ref:`ChoiceFilter <django-filter:choice-filter>` will only work with
47+
The default :class:`~django_filters.filters.ChoiceFilter` will only work with
4948
the enumeration values: ?color=R, ?color=G, ?color=B. ``EnumFilter`` will accept
5049
query parameter values from any of the symmetric properties: ?color=Red,
5150
?color=ff0000, etc...
@@ -54,66 +53,157 @@ class Color(TextChoices):
5453
filter on
5554
:param strict: If False (default), values not in the enumeration will
5655
be searchable.
57-
:param kwargs: Any additional arguments for base classes
56+
:param kwargs: Any additional arguments from the base classes
57+
(:class:`~django_filters.filters.TypedChoiceFilter`)
5858
"""
5959

6060
enum: t.Type[Enum]
6161
field_class = EnumChoiceField
6262

63-
def __init__(self, *, enum: t.Type[Enum], strict: bool = False, **kwargs):
63+
def __init__(self, *, enum: t.Type[Enum], **kwargs):
6464
self.enum = enum
6565
super().__init__(
6666
enum=enum,
6767
choices=kwargs.pop("choices", choices(self.enum)),
68-
strict=strict,
6968
**kwargs,
7069
)
7170

7271

7372
class MultipleEnumFilter(TypedMultipleChoiceFilter):
7473
"""
7574
Use this filter class instead of
76-
:ref:`MultipleChoiceFilter <django-filter:multiple-choice-filter>`
77-
to get filters to accept multiple :class:`~enum.Enum` labels and symmetric
78-
properties.
75+
:class:`~django_filters.filters.MultipleChoiceFilter` to get filters to accept
76+
multiple :class:`~enum.Enum` labels and symmetric properties.
7977
8078
:param enum: The class of the enumeration containing the values to
8179
filter on
8280
:param strict: If False (default), values not in the enumeration will
8381
be searchable.
84-
:param kwargs: Any additional arguments for base classes
82+
:param conjoined: If True require all values to be present, if False require any
83+
:param kwargs: Any additional arguments from base classes,
84+
(:class:`~django_filters.filters.TypedMultipleChoiceFilter`)
8585
"""
8686

8787
enum: t.Type[Enum]
8888
field_class = EnumMultipleChoiceField
8989

90-
def __init__(self, *, enum: t.Type[Enum], strict: bool = False, **kwargs):
90+
def __init__(
91+
self,
92+
*,
93+
enum: t.Type[Enum],
94+
conjoined: bool = False,
95+
**kwargs,
96+
):
97+
self.enum = enum
98+
super().__init__(
99+
enum=enum,
100+
choices=kwargs.pop("choices", choices(self.enum)),
101+
conjoined=conjoined,
102+
**kwargs,
103+
)
104+
105+
106+
class EnumFlagFilter(TypedMultipleChoiceFilter):
107+
"""
108+
Use this filter class instead of
109+
:class:`~django_filters.filters.MultipleChoiceFilter` to get filters to accept
110+
multiple :class:`~enum.Enum` labels and symmetric properties.
111+
112+
:param enum: The class of the enumeration containing the values to
113+
filter on
114+
:param strict: If False (default), values not in the enumeration will
115+
be searchable.
116+
:param conjoined: If True use :ref:`has_all` lookup, otherwise use :ref:`has_any`
117+
(default)
118+
:param kwargs: Any additional arguments from base classes,
119+
(:class:`~django_filters.filters.TypedMultipleChoiceFilter`)
120+
"""
121+
122+
enum: t.Type[Flag]
123+
field_class = EnumFlagField
124+
125+
def __init__(
126+
self,
127+
*,
128+
enum: t.Type[Flag],
129+
conjoined: bool = False,
130+
strict: bool = False,
131+
**kwargs,
132+
):
91133
self.enum = enum
134+
self.lookup_expr = "has_all" if conjoined else "has_any"
92135
super().__init__(
93136
enum=enum,
94137
choices=kwargs.pop("choices", choices(self.enum)),
95138
strict=strict,
139+
conjoined=conjoined,
96140
**kwargs,
97141
)
98142

143+
def filter(self, qs, value):
144+
if value == self.null_value:
145+
value = None
146+
147+
if not value:
148+
return qs
149+
150+
if self.is_noop(qs, value):
151+
return qs
152+
153+
qs = self.get_method(qs)(Q(**self.get_filter_predicate(value)))
154+
return qs.distinct() if self.distinct else qs
155+
99156

100157
class FilterSet(filterset.FilterSet):
101158
"""
102159
Use this class instead of the :doc:`django-filter <django-filter:index>`
103-
:doc:`FilterSet <django-filter:ref/filterset>` class to automatically set all
160+
:class:`~django_filters.filterset.FilterSet` to automatically set all
104161
:class:`~django_enum.fields.EnumField` filters to
105162
:class:`~django_enum.filters.EnumFilter` by default instead of
106-
:ref:`ChoiceFilter <django-filter:choice-filter>`.
163+
:class:`~django_filters.filters.ChoiceFilter`.
107164
"""
108165

166+
@staticmethod
167+
def enum_extra(f: EnumField) -> t.Dict[str, t.Any]:
168+
return {"enum": f.enum, "choices": f.choices}
169+
170+
FILTER_DEFAULTS = {
171+
**{
172+
FlagField: {
173+
"filter_class": EnumFlagFilter,
174+
"extra": enum_extra,
175+
},
176+
EnumField: {
177+
"filter_class": EnumFilter,
178+
"extra": enum_extra,
179+
},
180+
},
181+
**filterset.FilterSet.FILTER_DEFAULTS,
182+
}
183+
109184
@classmethod
110185
def filter_for_lookup(
111186
cls, field: ModelField, lookup_type: str
112187
) -> t.Tuple[t.Optional[t.Type[Filter]], t.Dict[str, t.Any]]:
113188
"""For EnumFields use the EnumFilter class by default"""
114189
if isinstance(field, EnumField):
115-
return EnumFilter, {
116-
"enum": field.enum,
117-
"strict": getattr(field, "strict", False),
118-
}
190+
data = (
191+
try_dbfield(
192+
{
193+
**cls.FILTER_DEFAULTS,
194+
**(
195+
getattr(getattr(cls, "_meta", None), "filter_overrides", {})
196+
),
197+
}.get,
198+
field.__class__,
199+
)
200+
or {}
201+
)
202+
return (
203+
data["filter_class"],
204+
{
205+
**cls.enum_extra(field),
206+
**data.get("extra", lambda f: {})(field),
207+
},
208+
)
119209
return super().filter_for_lookup(field, lookup_type)

tests/djenum/urls.py

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,9 @@
3939

4040
from tests.djenum.views import (
4141
EnumTesterFilterViewSet,
42+
EnumTesterFilterExcludeViewSet,
4243
EnumTesterMultipleFilterViewSet,
44+
EnumTesterMultipleFilterExcludeViewSet,
4345
)
4446

4547
urlpatterns.extend(
@@ -54,15 +56,25 @@
5456
name="enum-filter",
5557
),
5658
path(
57-
"enum/filter/symmetric/",
59+
"enum/filter/viewset/",
5860
EnumTesterFilterViewSet.as_view(),
59-
name="enum-filter-symmetric",
61+
name="enum-filter-viewset",
62+
),
63+
path(
64+
"enum/filter/viewset/exclude",
65+
EnumTesterFilterExcludeViewSet.as_view(),
66+
name="enum-filter-viewset-exclude",
6067
),
6168
path(
6269
"enum/filter/multiple/",
6370
EnumTesterMultipleFilterViewSet.as_view(),
6471
name="enum-filter-multiple",
6572
),
73+
path(
74+
"enum/filter/multiple/exclude/",
75+
EnumTesterMultipleFilterExcludeViewSet.as_view(),
76+
name="enum-filter-multiple-exclude",
77+
),
6678
]
6779
)
6880
except ImportError: # pragma: no cover

tests/djenum/views.py

Lines changed: 77 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -94,8 +94,12 @@ class DRFView(viewsets.ModelViewSet):
9494

9595
try:
9696
from django_filters.views import FilterView
97-
98-
from django_enum.filters import FilterSet as EnumFilterSet, MultipleEnumFilter
97+
from django_enum.fields import EnumField
98+
from django_enum.filters import (
99+
FilterSet as EnumFilterSet,
100+
EnumFilter,
101+
MultipleEnumFilter,
102+
)
99103

100104
from .enums import (
101105
BigIntEnum,
@@ -126,6 +130,18 @@ class Meta:
126130
model = EnumTester
127131
template_name = "enumtester_list.html"
128132

133+
class EnumTesterFilterExcludeViewSet(EnumTesterFilterViewSet):
134+
class EnumTesterExcludeFilter(EnumTesterFilterViewSet.EnumTesterFilter):
135+
class Meta(EnumTesterFilterViewSet.EnumTesterFilter.Meta):
136+
filter_overrides = {
137+
EnumField: {
138+
"filter_class": EnumFilter,
139+
"extra": lambda f: {"exclude": True},
140+
}
141+
}
142+
143+
filterset_class = EnumTesterExcludeFilter
144+
129145
class EnumTesterMultipleFilterViewSet(URLMixin, FilterView):
130146
class EnumTesterMultipleFilter(EnumFilterSet):
131147
small_pos_int = MultipleEnumFilter(enum=SmallPosIntEnum)
@@ -179,5 +195,64 @@ class Meta:
179195
model = EnumTester
180196
template_name = "enumtester_list.html"
181197

198+
class EnumTesterMultipleFilterExcludeViewSet(URLMixin, FilterView):
199+
class EnumTesterMultipleFilter(EnumFilterSet):
200+
small_pos_int = MultipleEnumFilter(enum=SmallPosIntEnum, exclude=True)
201+
small_int = MultipleEnumFilter(enum=SmallIntEnum, exclude=True)
202+
pos_int = MultipleEnumFilter(enum=PosIntEnum, exclude=True)
203+
int = MultipleEnumFilter(enum=IntEnum, exclude=True)
204+
big_pos_int = MultipleEnumFilter(enum=BigPosIntEnum, exclude=True)
205+
big_int = MultipleEnumFilter(enum=BigIntEnum, exclude=True)
206+
constant = MultipleEnumFilter(enum=Constants, exclude=True)
207+
text = MultipleEnumFilter(enum=TextEnum, exclude=True)
208+
extern = MultipleEnumFilter(enum=ExternEnum, exclude=True)
209+
210+
dj_int_enum = MultipleEnumFilter(enum=DJIntEnum, exclude=True)
211+
dj_text_enum = MultipleEnumFilter(enum=DJTextEnum, exclude=True)
212+
213+
# Non-strict
214+
non_strict_int = MultipleEnumFilter(
215+
enum=SmallPosIntEnum, strict=False, exclude=True
216+
)
217+
non_strict_text = MultipleEnumFilter(
218+
enum=TextEnum, strict=False, exclude=True
219+
)
220+
no_coerce = MultipleEnumFilter(
221+
enum=SmallPosIntEnum, strict=False, exclude=True
222+
)
223+
224+
# eccentric enums
225+
date_enum = MultipleEnumFilter(enum=DateEnum, exclude=True)
226+
datetime_enum = MultipleEnumFilter(enum=DateTimeEnum, exclude=True)
227+
time_enum = MultipleEnumFilter(enum=TimeEnum, exclude=True)
228+
duration_enum = MultipleEnumFilter(enum=DurationEnum, exclude=True)
229+
decimal_enum = MultipleEnumFilter(enum=DecimalEnum, exclude=True)
230+
231+
class Meta:
232+
fields = [
233+
"small_pos_int",
234+
"small_int",
235+
"pos_int",
236+
"int",
237+
"big_pos_int",
238+
"big_int",
239+
"constant",
240+
"text",
241+
"extern",
242+
"non_strict_int",
243+
"non_strict_text",
244+
"no_coerce",
245+
"date_enum",
246+
"datetime_enum",
247+
"time_enum",
248+
"duration_enum",
249+
"decimal_enum",
250+
]
251+
model = EnumTester
252+
253+
filterset_class = EnumTesterMultipleFilter
254+
model = EnumTester
255+
template_name = "enumtester_list.html"
256+
182257
except ImportError: # pragma: no cover
183258
pass

tests/enum_prop/urls.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,15 +41,15 @@
4141

4242
urlpatterns.extend(
4343
[
44-
path(
45-
"enum/filter/",
46-
FilterView.as_view(
47-
model=EnumTester,
48-
filterset_fields="__all__",
49-
template_name="enumtester_list.html",
50-
),
51-
name="enum-filter",
52-
),
44+
# path(
45+
# "enum/filter/",
46+
# FilterView.as_view(
47+
# model=EnumTester,
48+
# filterset_fields="__all__",
49+
# template_name="enumtester_list.html",
50+
# ),
51+
# name="enum-filter",
52+
# ),
5353
path(
5454
"enum/filter/symmetric/",
5555
EnumTesterPropFilterViewSet.as_view(),

0 commit comments

Comments
 (0)