Skip to content

Commit

Permalink
Merge pull request #494 from rpkilby/user-filtering
Browse files Browse the repository at this point in the history
Add QuerySetRequestMixin
  • Loading branch information
Carlton Gibson authored Nov 2, 2016
2 parents d7497e4 + 9898ccb commit 130fc00
Show file tree
Hide file tree
Showing 6 changed files with 206 additions and 8 deletions.
55 changes: 53 additions & 2 deletions django_filters/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -311,11 +311,62 @@ class DurationFilter(Filter):
field_class = forms.DurationField


class ModelChoiceFilter(Filter):
class QuerySetRequestMixin(object):
"""
Add callable functionality to filters that support the ``queryset``
argument. If the ``queryset`` is callable, then it **must** accept the
``request`` object as a single argument.
This is useful for filtering querysets by properties on the ``request``
object, such as the user.
Example::
def departments(request):
company = request.user.company
return company.department_set.all()
class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...
The above example restricts the set of departments to those in the logged-in
user's associated company.
"""
def __init__(self, *args, **kwargs):
self.queryset = kwargs.get('queryset')
super(QuerySetRequestMixin, self).__init__(*args, **kwargs)

def get_request(self):
try:
return self.parent.request
except AttributeError:
return None

def get_queryset(self, request):
queryset = self.queryset

if callable(queryset):
return queryset(request)
return queryset

@property
def field(self):
request = self.get_request()
queryset = self.get_queryset(request)

if queryset is not None:
self.extra['queryset'] = queryset

return super(QuerySetRequestMixin, self).field


class ModelChoiceFilter(QuerySetRequestMixin, Filter):
field_class = forms.ModelChoiceField


class ModelMultipleChoiceFilter(MultipleChoiceFilter):
class ModelMultipleChoiceFilter(QuerySetRequestMixin, MultipleChoiceFilter):
field_class = forms.ModelMultipleChoiceField


Expand Down
17 changes: 17 additions & 0 deletions docs/ref/filters.txt
Original file line number Diff line number Diff line change
Expand Up @@ -323,6 +323,22 @@ Example::
model = Book
fields = ['author']

The ``queryset`` argument also supports callable behavior. If a callable is
passed, it will be invoked with ``Filterset.request`` as its only argument.
This allows you to easily filter by properties on the request object without
having to override the ``FilterSet.__init__``.

.. code-block:: python

def departments(request):
company = request.user.company
return company.department_set.all()

class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...


``ModelMultipleChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

Expand All @@ -332,6 +348,7 @@ for ``ManyToManyField`` by default.
As with ``ModelChoiceFilter``, if automatically instantiated,
``ModelMultipleChoiceFilter`` will use the default ``QuerySet`` for the related
field. If manually instantiated you **must** provide the ``queryset`` kwarg.
Like ``ModelChoiceFilter``, the ``queryset`` argument has callable behavior.

To use a custom field name for the lookup, you can use ``to_field_name``::

Expand Down
52 changes: 52 additions & 0 deletions docs/usage.txt
Original file line number Diff line number Diff line change
Expand Up @@ -236,11 +236,63 @@ default filters for all the models fields of the same kind using
}


Request-based filtering
~~~~~~~~~~~~~~~~~~~~~~~

The ``FilterSet`` may be initialized with an optional ``request`` argument. If
a request object is passed, then you may access the request during filtering.
This allows you to filter by properties on the request, such as the currently
logged-in user or the ``Accepts-Languages`` header.


Filtering the primary ``.qs``
"""""""""""""""""""""""""""""

To filter the primary queryset by the ``request`` object, simply override the
``FilterSet.qs`` property. For example, you could filter blog articles to only
those that are published and those that are owned by the logged-in user
(presumably the author's draft articles).

.. code-block:: python

class ArticleFilter(django_filters.FilterSet):

class Meta:
model = Article
fields = [...]

@property
def qs(self):
parent = super(ArticleFilter, self).qs
return parent.filter(is_published=True) \
| parent.filter(author=request.user)


Filtering the related queryset for ``ModelChoiceFilter``
"""""""""""""""""""""""""""""""""""""""""""""""""""""""

The ``queryset`` argument for ``ModelChoiceFilter`` and ``ModelMultipleChoiceFilter``
supports callable behavior. If a callable is passed, it will be invoked with the
``request`` as its only argument. This allows you to perform the same kinds of
request-based filtering without resorting to overriding ``FilterSet.__init__``.

.. code-block:: python

def departments(request):
company = request.user.company
return company.department_set.all()

class EmployeeFilter(filters.FilterSet):
department = filters.ModelChoiceFilter(queryset=departments)
...


Customize filtering with ``Filter.method``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

You can control the behavior of a filter by specifying a ``method`` to perform
filtering. View more information in the :ref:`method reference <filter-method>`.
Note that you may access the filterset's properties, such as the ``request``.

.. code-block:: python

Expand Down
36 changes: 30 additions & 6 deletions tests/test_filtering.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,24 +20,20 @@
from django_filters.filters import DateRangeFilter
from django_filters.filters import DateFromToRangeFilter
from django_filters.filters import DateTimeFromToRangeFilter
# from django_filters.filters import DateTimeFilter
from django_filters.filters import DurationFilter
from django_filters.filters import MultipleChoiceFilter
from django_filters.filters import ModelChoiceFilter
from django_filters.filters import ModelMultipleChoiceFilter
from django_filters.filters import NumberFilter
from django_filters.filters import OrderingFilter
from django_filters.filters import RangeFilter
from django_filters.filters import TimeRangeFilter
# from django_filters.widgets import LinkWidget
from django_filters.exceptions import FieldLookupError

from .models import User
from .models import Comment
from .models import Book
# from .models import Restaurant
from .models import Article
# from .models import NetworkSetting
# from .models import SubnetMaskField
from .models import Company
from .models import Location
from .models import Account
Expand Down Expand Up @@ -418,6 +414,34 @@ class Meta:
f = F({'author': jacob.pk}, queryset=qs)
self.assertQuerysetEqual(f.qs, [1, 3], lambda o: o.pk, False)

def test_callable_queryset(self):
# Sanity check for callable queryset arguments.
# Ensure that nothing is improperly cached
User.objects.create(username='alex')
jacob = User.objects.create(username='jacob')
aaron = User.objects.create(username='aaron')

def users(request):
return User.objects.filter(pk__lt=request.user.pk)

class F(FilterSet):
author = ModelChoiceFilter(name='author', queryset=users)

class Meta:
model = Comment
fields = ['author']

qs = Comment.objects.all()
request = mock.Mock()

request.user = jacob
f = F(queryset=qs, request=request).filters['author'].field
self.assertQuerysetEqual(f.queryset, [1], lambda o: o.pk, False)

request.user = aaron
f = F(queryset=qs, request=request).filters['author'].field
self.assertQuerysetEqual(f.queryset, [1, 2], lambda o: o.pk, False)


class ModelMultipleChoiceFilterTests(TestCase):

Expand Down Expand Up @@ -1644,7 +1668,7 @@ class Meta:
model = User
fields = ['account']

qs = mock.MagicMock()
qs = mock.NonCallableMagicMock()
f = F({'account': 'jdoe'}, queryset=qs)
result = f.qs
self.assertNotEqual(qs, result)
Expand Down
40 changes: 40 additions & 0 deletions tests/test_filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -523,6 +523,33 @@ def test_default_field_with_queryset(self):
self.assertIsInstance(field, forms.ModelChoiceField)
self.assertEqual(field.queryset, qs)

def test_callable_queryset(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])

qs_callable = mock.Mock(return_value=qs)

f = ModelChoiceFilter(queryset=qs_callable)
f.parent = mock.Mock(request=request)
field = f.field

qs_callable.assert_called_with(request)
self.assertEqual(field.queryset, qs)

def test_get_queryset_override(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])

class F(ModelChoiceFilter):
get_queryset = mock.create_autospec(ModelChoiceFilter.get_queryset, return_value=qs)

f = F()
f.parent = mock.Mock(request=request)
field = f.field

f.get_queryset.assert_called_with(f, request)
self.assertEqual(field.queryset, qs)


class ModelMultipleChoiceFilterTests(TestCase):

Expand Down Expand Up @@ -553,6 +580,19 @@ def test_filtering_to_field_name(self):
self.assertEqual(list(f.filter(qs, ['Firstname'])), [user])
self.assertEqual(list(f.filter(qs, [user])), [user])

def test_callable_queryset(self):
request = mock.NonCallableMock(spec=[])
qs = mock.NonCallableMock(spec=[])

qs_callable = mock.Mock(return_value=qs)

f = ModelMultipleChoiceFilter(queryset=qs_callable)
f.parent = mock.Mock(request=request)
field = f.field

qs_callable.assert_called_with(request)
self.assertEqual(field.queryset, qs)


class NumberFilterTests(TestCase):

Expand Down
14 changes: 14 additions & 0 deletions tests/test_filterset.py
Original file line number Diff line number Diff line change
Expand Up @@ -688,6 +688,20 @@ def test_parent_unresolvable(self):
self.assertIn('parent', str(w.exception))
self.assertIn('filter_f', str(w.exception))

def test_method_self_is_parent(self):
# Ensure the method isn't 're-parented' on the `FilterMethod` helper class.
# Filter methods should have access to the filterset's properties.
request = mock.Mock()

class F(FilterSet):
f = CharFilter(method='filter_f')

def filter_f(inner_self, qs, name, value):
self.assertIsInstance(inner_self, F)
self.assertIs(inner_self.request, request)

F({'f': 'foo'}, request=request, queryset=User.objects.all()).qs

def test_method_unresolvable(self):
class F(FilterSet):
f = Filter(method='filter_f')
Expand Down

0 comments on commit 130fc00

Please sign in to comment.