Skip to content

Commit 90165a0

Browse files
committed
Merge pull request #47 from rpkilby/method-filter-rework
Method filter rework
2 parents de16e28 + 236d11a commit 90165a0

File tree

6 files changed

+186
-3
lines changed

6 files changed

+186
-3
lines changed

README.rst

Lines changed: 54 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ then we can filter like so::
177177
/api/page/?author__username__icontains=john
178178

179179
Automatic Filter Negation/Exclusion
180-
~~~~~~~~~~~~~~~~~~~~~~~~~
180+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
181181

182182
FilterSets also support automatic exclusion using a simple ``k!=v`` syntax. This syntax
183183
internally sets the ``exclude`` property on the filter.
@@ -190,8 +190,60 @@ excluding those containing "World".
190190

191191
/api/articles/?title__contains=Hello&title__contains!=World
192192

193+
MethodFilter Reimplementation
194+
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
195+
196+
``MethodFilter`` has been reimplemented to work across relationships. This is not a
197+
forwards-compatible change and requires adding a minimal amount of boilerplate to the
198+
filter method.
199+
200+
When filtering across relationships, the queryset and lookup value will be different.
201+
For example:
202+
203+
class PostFilter(filters.FilterSet):
204+
author = filters.RelatedFilter('AuthorFilter')
205+
is_published = filters.MethodFilter()
206+
207+
class Meta:
208+
model = Post
209+
fields = ['title', 'content']
210+
211+
def filter_is_published(self, name, qs, value):
212+
# convert value to boolean
213+
null = value.lower() != 'true'
214+
215+
# The lookup name will end with `is_published`, but could be
216+
# preceded by a related lookup path.
217+
if LOOKUP_SEP in name:
218+
rel, _ = name.rsplit(LOOKUP_SEP, 1)
219+
name = LOOKUP_SEP.join([rel, 'date_published__isnull'])
220+
else:
221+
name = 'date_published__isnull'
222+
223+
return qs.filter(**{name: null})
224+
225+
class AuthorFilter(filters.FilterSet):
226+
posts = filters.RelatedFilter('PostFilter')
227+
228+
class Meta:
229+
model = Author
230+
fields = ['name']
231+
232+
And given these API calls:
233+
234+
/api/posts/?is_published=true
235+
236+
/api/authors/?posts__is_published=true
237+
238+
239+
In the first API call, the filter method receives a queryset of posts. In the second,
240+
it receives a queryset of users. The filter method in the example modifies the lookup
241+
name to work across the relationship, allowing you to find published posts, or authors
242+
who have published posts.
243+
244+
193245
DjangoFilterBackend
194-
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
246+
~~~~~~~~~~~~~~~~~~~
195247

196248
We implement our own subclass of ``DjangoFilterBackend``, which you should probably use instead
197249
of the default ``DjangoFilterBackend``. Our ``DjangoFilterBackend`` caches repeated filter set

rest_framework_filters/filters.py

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,3 +87,47 @@ class InSetNumberFilter(InSetFilterBase, NumberFilter):
8787

8888
class InSetCharFilter(InSetFilterBase, NumberFilter):
8989
field_class = fields.ArrayCharField
90+
91+
92+
class MethodFilter(Filter):
93+
"""
94+
This filter will allow you to run a method that exists on the filterset class
95+
"""
96+
97+
def __init__(self, *args, **kwargs):
98+
self.action = kwargs.pop('action', '')
99+
super(MethodFilter, self).__init__(*args, **kwargs)
100+
101+
def resolve_action(self):
102+
"""
103+
This method provides a hook for the parent FilterSet to resolve the filter's
104+
action after initialization. This is necessary, as the filter name may change
105+
as it's expanded across related filtersets.
106+
107+
ie, `is_published` might become `post__is_published`.
108+
"""
109+
# noop if a function was provided as the action
110+
if callable(self.action):
111+
return
112+
113+
# otherwise, action is a string representing an action to be called on
114+
# the parent FilterSet.
115+
parent_action = self.action or 'filter_{0}'.format(self.name)
116+
117+
parent = getattr(self, 'parent', None)
118+
self.action = getattr(parent, parent_action, None)
119+
120+
assert callable(self.action), (
121+
'Expected parent FilterSet `%s.%s` to have a `.%s()` method.' %
122+
(parent.__class__.__module__, parent.__class__.__name__, parent_action)
123+
)
124+
125+
def filter(self, qs, value):
126+
"""
127+
This filter method will act as a proxy for the actual method we want to
128+
call.
129+
It will try to find the method on the parent filterset,
130+
if not it attempts to search for the method `field_{{attribute_name}}`.
131+
Otherwise it defaults to just returning the queryset.
132+
"""
133+
return self.action(self.name, qs, value)

rest_framework_filters/filterset.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,9 @@ def __init__(self, *args, **kwargs):
7373
if isnull not in self.filters:
7474
self.filters[isnull] = filters.BooleanFilter(name=isnull)
7575

76+
elif isinstance(filter_, filters.MethodFilter):
77+
filter_.resolve_action()
78+
7679
def get_filters(self):
7780
"""
7881
Build a set of filters based on the requested data. The resulting set

tests/filters.py

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11

22
from rest_framework_filters import filters
33
from rest_framework_filters.filters import RelatedFilter, AllLookupsFilter
4-
from rest_framework_filters.filterset import FilterSet
4+
from rest_framework_filters.filterset import FilterSet, LOOKUP_SEP
55

66

77
from .models import (
@@ -63,6 +63,40 @@ class Meta:
6363
model = Post
6464

6565

66+
class PostFilterWithMethod(FilterSet):
67+
note = RelatedFilter(NoteFilterWithRelatedAll, name='note')
68+
is_published = filters.MethodFilter()
69+
70+
class Meta:
71+
model = Post
72+
73+
def filter_is_published(self, name, qs, value):
74+
"""
75+
`is_published` is based on the actual `date_published`.
76+
If the publishing date is null, then the post is not published.
77+
"""
78+
# convert value to boolean
79+
null = value.lower() != 'true'
80+
81+
# The lookup name will end with `is_published`, but could be
82+
# preceded by a related lookup path.
83+
if LOOKUP_SEP in name:
84+
rel, _ = name.rsplit(LOOKUP_SEP, 1)
85+
name = LOOKUP_SEP.join([rel, 'date_published__isnull'])
86+
else:
87+
name = 'date_published__isnull'
88+
89+
return qs.filter(**{name: null})
90+
91+
92+
class CoverFilterWithRelatedMethodFilter(FilterSet):
93+
comment = filters.CharFilter(name='comment')
94+
post = RelatedFilter(PostFilterWithMethod, name='post')
95+
96+
class Meta:
97+
model = Cover
98+
99+
66100
class CoverFilterWithRelated(FilterSet):
67101
comment = filters.CharFilter(name='comment')
68102
post = RelatedFilter(PostFilterWithRelated, name='post')

tests/models.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ class Note(models.Model):
1212
class Post(models.Model):
1313
note = models.ForeignKey(Note)
1414
content = models.TextField()
15+
date_published = models.DateField(null=True)
1516

1617

1718
class Cover(models.Model):

tests/test_filterset.py

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222
NoteFilterWithRelatedAll,
2323
NoteFilterWithRelatedAllDifferentFilterName,
2424
PostFilterWithRelated,
25+
PostFilterWithMethod,
26+
CoverFilterWithRelatedMethodFilter,
2527
CoverFilterWithRelated,
2628
# PageFilterWithRelated,
2729
TagFilter,
@@ -316,6 +318,53 @@ def test_get_filterset_subset(self):
316318
self.assertEqual(len(filterset_class.base_filters), 1)
317319

318320

321+
class MethodFilterTests(TestCase):
322+
323+
if django.VERSION >= (1, 8):
324+
@classmethod
325+
def setUpTestData(cls):
326+
cls.generateTestData()
327+
328+
else:
329+
def setUp(self):
330+
self.generateTestData()
331+
332+
@classmethod
333+
def generateTestData(cls):
334+
user = User.objects.create(username="user1", email="user1@example.org")
335+
336+
note1 = Note.objects.create(title="Test 1", content="Test content 1", author=user)
337+
note2 = Note.objects.create(title="Test 2", content="Test content 2", author=user)
338+
339+
post1 = Post.objects.create(note=note1, content="Test content in post 1")
340+
post2 = Post.objects.create(note=note2, content="Test content in post 2", date_published=datetime.date.today())
341+
342+
Cover.objects.create(post=post1, comment="Cover 1")
343+
Cover.objects.create(post=post2, comment="Cover 2")
344+
345+
def test_method_filter(self):
346+
GET = {
347+
'is_published': 'true'
348+
}
349+
filterset = PostFilterWithMethod(GET, queryset=Post.objects.all())
350+
results = list(filterset)
351+
self.assertEqual(len(results), 1)
352+
self.assertEqual(results[0].content, "Test content in post 2")
353+
354+
def test_related_method_filter(self):
355+
"""
356+
Missing MethodFilter filter methods are silently ignored, returning
357+
the unfiltered queryset.
358+
"""
359+
GET = {
360+
'post__is_published': 'true'
361+
}
362+
filterset = CoverFilterWithRelatedMethodFilter(GET, queryset=Cover.objects.all())
363+
results = list(filterset)
364+
self.assertEqual(len(results), 1)
365+
self.assertEqual(results[0].comment, "Cover 2")
366+
367+
319368
class DatetimeTests(TestCase):
320369

321370
@classmethod

0 commit comments

Comments
 (0)