Rework ordering into a filter
This commit is contained in:
parent
016840bb20
commit
569efd4e46
|
@ -2,6 +2,7 @@ from __future__ import absolute_import
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import warnings
|
||||
from collections import OrderedDict
|
||||
from datetime import timedelta
|
||||
|
||||
from django import forms
|
||||
|
@ -10,6 +11,7 @@ from django.db.models.sql.constants import QUERY_TERMS
|
|||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.conf import settings
|
||||
from django.utils import six
|
||||
from django.utils.itercompat import is_iterable
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import ugettext_lazy as _
|
||||
|
||||
|
@ -17,7 +19,7 @@ from .fields import (
|
|||
Lookup, LookupTypeField, BaseCSVField, BaseRangeField, RangeField,
|
||||
DateRangeField, DateTimeRangeField, TimeRangeField, IsoDateTimeField
|
||||
)
|
||||
from .utils import deprecate
|
||||
from .utils import deprecate, pretty_name
|
||||
|
||||
|
||||
__all__ = [
|
||||
|
@ -39,6 +41,7 @@ __all__ = [
|
|||
'MultipleChoiceFilter',
|
||||
'NumberFilter',
|
||||
'NumericRangeFilter',
|
||||
'OrderingFilter',
|
||||
'RangeFilter',
|
||||
'TimeFilter',
|
||||
'TimeRangeFilter',
|
||||
|
@ -489,6 +492,102 @@ class BaseRangeFilter(BaseCSVFilter):
|
|||
super(BaseRangeFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
|
||||
class OrderingFilter(BaseCSVFilter, ChoiceFilter):
|
||||
"""
|
||||
Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts
|
||||
two additional arguments that are used to build the ordering choices.
|
||||
|
||||
* ``fields`` is a mapping of {model field name: parameter name}. The
|
||||
parameter names are exposed in the choices and mask/alias the field
|
||||
names used in the ``order_by()`` call. Similar to field ``choices``,
|
||||
``fields`` accepts the 'list of two-tuples' syntax that retains order.
|
||||
``fields`` may also just be an iterable of strings. In this case, the
|
||||
field names simply double as the exposed parameter names.
|
||||
|
||||
* ``field_labels`` is an optional argument that allows you to customize
|
||||
the display label for the corresponding parameter. It accepts a mapping
|
||||
of {field name: human readable label}. Keep in mind that the key is the
|
||||
field name, and not the exposed parameter name.
|
||||
|
||||
Additionally, you can just provide your own ``choices`` if you require
|
||||
explicit control over the exposed options. For example, when you might
|
||||
want to disable descending sort options.
|
||||
|
||||
This filter is also CSV-based, and accepts multiple ordering params. The
|
||||
default select widget does not enable the use of this, but it is useful
|
||||
for APIs.
|
||||
|
||||
"""
|
||||
descending_fmt = _('%s (descending)')
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
"""
|
||||
``fields`` may be either a mapping or an iterable.
|
||||
``field_labels`` must be a map of field names to display labels
|
||||
"""
|
||||
fields = kwargs.pop('fields', {})
|
||||
fields = self.normalize_fields(fields)
|
||||
field_labels = kwargs.pop('field_labels', {})
|
||||
|
||||
self.param_map = {v: k for k, v in fields.items()}
|
||||
|
||||
if 'choices' not in kwargs:
|
||||
kwargs['choices'] = self.build_choices(fields, field_labels)
|
||||
|
||||
kwargs.setdefault('label', _('Ordering'))
|
||||
super(OrderingFilter, self).__init__(*args, **kwargs)
|
||||
|
||||
def get_ordering_value(self, param):
|
||||
descending = param.startswith('-')
|
||||
param = param[1:] if descending else param
|
||||
field_name = self.param_map.get(param, param)
|
||||
|
||||
return "-%s" % field_name if descending else field_name
|
||||
|
||||
def filter(self, qs, value):
|
||||
if value in EMPTY_VALUES:
|
||||
return qs
|
||||
|
||||
ordering = [self.get_ordering_value(param) for param in value]
|
||||
return qs.order_by(*ordering)
|
||||
|
||||
@classmethod
|
||||
def normalize_fields(cls, fields):
|
||||
"""
|
||||
Normalize the fields into an ordered map of {field name: param name}
|
||||
"""
|
||||
# fields is a mapping, copy into new OrderedDict
|
||||
if isinstance(fields, dict):
|
||||
return OrderedDict(fields)
|
||||
|
||||
# convert iterable of values => iterable of pairs (field name, param name)
|
||||
assert is_iterable(fields), \
|
||||
"'fields' must be an iterable (e.g., a list, tuple, or mapping)."
|
||||
|
||||
# fields is an iterable of field names
|
||||
assert all(isinstance(field, six.string_types) or
|
||||
is_iterable(field) and len(field) == 2 # may need to be wrapped in parens
|
||||
for field in fields), \
|
||||
"'fields' must contain strings or (field name, param name) pairs."
|
||||
|
||||
return OrderedDict([
|
||||
(f, f) if isinstance(f, six.string_types) else f for f in fields
|
||||
])
|
||||
|
||||
def build_choices(self, fields, labels):
|
||||
ascending = [
|
||||
(param, labels.get(field, pretty_name(param)))
|
||||
for field, param in fields.items()
|
||||
]
|
||||
descending = [
|
||||
('-%s' % pair[0], self.descending_fmt % pair[1])
|
||||
for pair in ascending
|
||||
]
|
||||
|
||||
# interleave the ascending and descending choices
|
||||
return [val for pair in zip(ascending, descending) for val in pair]
|
||||
|
||||
|
||||
class MethodFilter(Filter):
|
||||
"""
|
||||
This filter will allow you to run a method that exists on the filterset class
|
||||
|
|
|
@ -2,12 +2,10 @@ from __future__ import absolute_import
|
|||
from __future__ import unicode_literals
|
||||
|
||||
import copy
|
||||
import re
|
||||
from collections import OrderedDict
|
||||
|
||||
from django import forms
|
||||
from django.forms.forms import NON_FIELD_ERRORS
|
||||
from django.core.validators import EMPTY_VALUES
|
||||
from django.db import models
|
||||
from django.db.models.constants import LOOKUP_SEP
|
||||
from django.db.models.fields.related import ForeignObjectRel
|
||||
|
@ -19,8 +17,8 @@ from .compat import remote_field, remote_queryset
|
|||
from .filters import (Filter, CharFilter, BooleanFilter, BaseInFilter, BaseRangeFilter,
|
||||
ChoiceFilter, DateFilter, DateTimeFilter, TimeFilter, ModelChoiceFilter,
|
||||
ModelMultipleChoiceFilter, NumberFilter, UUIDFilter,
|
||||
DurationFilter)
|
||||
from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, deprecate
|
||||
DurationFilter, OrderingFilter)
|
||||
from .utils import try_dbfield, get_all_model_fields, get_model_field, resolve_field, pretty_name, deprecate
|
||||
|
||||
|
||||
ORDER_BY_FIELD = 'o'
|
||||
|
@ -205,6 +203,11 @@ class FilterSetMetaclass(type):
|
|||
raise TypeError("Meta.fields contains a field that isn't defined "
|
||||
"on this FilterSet: {}".format(not_defined))
|
||||
|
||||
# check key existence instead of setdefault - prevents unnecessary filter construction
|
||||
order_by_field = new_class._meta.order_by_field
|
||||
if opts.order_by and order_by_field not in filters:
|
||||
filters[order_by_field] = new_class.get_ordering_filter(opts, filters)
|
||||
|
||||
new_class.declared_filters = declared_filters
|
||||
new_class.base_filters = filters
|
||||
return new_class
|
||||
|
@ -330,23 +333,6 @@ class BaseFilterSet(object):
|
|||
if value is not None: # valid & clean data
|
||||
qs = filter_.filter(qs, value)
|
||||
|
||||
if self._meta.order_by:
|
||||
order_field = self.form.fields[self._meta.order_by_field]
|
||||
data = self.form[self._meta.order_by_field].data
|
||||
ordered_value = None
|
||||
try:
|
||||
ordered_value = order_field.clean(data)
|
||||
except forms.ValidationError:
|
||||
pass
|
||||
|
||||
# With a None-queryset, ordering must be enforced (#84).
|
||||
if (ordered_value in EMPTY_VALUES and
|
||||
self.strict == STRICTNESS.RETURN_NO_RESULTS):
|
||||
ordered_value = self.form.fields[self._meta.order_by_field].choices[0][0]
|
||||
|
||||
if ordered_value:
|
||||
qs = qs.order_by(*self.get_order_by(ordered_value))
|
||||
|
||||
self._qs = qs
|
||||
|
||||
return self._qs
|
||||
|
@ -357,7 +343,7 @@ class BaseFilterSet(object):
|
|||
fields = OrderedDict([
|
||||
(name, filter_.field)
|
||||
for name, filter_ in six.iteritems(self.filters)])
|
||||
fields[self._meta.order_by_field] = self.ordering_field
|
||||
|
||||
Form = type(str('%sForm' % self.__class__.__name__),
|
||||
(self._meta.form,), fields)
|
||||
if self._meta.together:
|
||||
|
@ -368,49 +354,48 @@ class BaseFilterSet(object):
|
|||
self._form = Form(prefix=self.form_prefix)
|
||||
return self._form
|
||||
|
||||
def get_ordering_field(self):
|
||||
if self._meta.order_by:
|
||||
if isinstance(self._meta.order_by, (list, tuple)):
|
||||
if isinstance(self._meta.order_by[0], (list, tuple)):
|
||||
# e.g. (('field', 'Display name'), ...)
|
||||
choices = [(f[0], f[1]) for f in self._meta.order_by]
|
||||
else:
|
||||
choices = []
|
||||
for f in self._meta.order_by:
|
||||
if f[0] == '-':
|
||||
label = _('%s (descending)' % capfirst(f[1:]))
|
||||
else:
|
||||
label = capfirst(f)
|
||||
choices.append((f, label))
|
||||
@classmethod
|
||||
def get_ordering_filter(cls, opts, filters):
|
||||
assert not isinstance(opts.fields, dict), \
|
||||
"'order_by' is not compatible with the 'fields' dict syntax. Use OrderingFilter instead."
|
||||
|
||||
def display_text(name, fltr):
|
||||
"""
|
||||
``name`` is the filter's attribute name on the FilterSet
|
||||
``text`` is the current display text, which is either the ``name``
|
||||
or an explicitly assigned label.
|
||||
"""
|
||||
# TODO: use `fltr._label` in label-improvements branch
|
||||
text = fltr.label or name.lstrip('-')
|
||||
if name.startswith('-'):
|
||||
text = _('%s (descending)' % text)
|
||||
|
||||
return pretty_name(text)
|
||||
|
||||
if isinstance(opts.order_by, (list, tuple)):
|
||||
|
||||
# e.g. (('field', 'Display name'), ...)
|
||||
if isinstance(opts.order_by[0], (list, tuple)):
|
||||
choices = [(f[0], f[1]) for f in opts.order_by]
|
||||
fields = {filters.get(f[0].lstrip('-')).name: f[0] for f in opts.order_by}
|
||||
return OrderingFilter(choices=choices, fields=fields)
|
||||
|
||||
# e.g. ('field1', 'field2', ...)
|
||||
else:
|
||||
# add asc and desc field names
|
||||
# use the filter's label if provided
|
||||
choices = []
|
||||
for f, fltr in self.filters.items():
|
||||
choices.extend([
|
||||
(f, fltr.label or capfirst(f)),
|
||||
("-%s" % (f), _('%s (descending)' % (fltr.label or capfirst(f))))
|
||||
])
|
||||
return forms.ChoiceField(label=_("Ordering"), required=False,
|
||||
choices=choices)
|
||||
# (filter name, filter instance)
|
||||
order_by = [(f, filters.get(f.lstrip('-'))) for f in opts.order_by]
|
||||
|
||||
@property
|
||||
def ordering_field(self):
|
||||
if not hasattr(self, '_ordering_field'):
|
||||
self._ordering_field = self.get_ordering_field()
|
||||
return self._ordering_field
|
||||
# preference filter label over attribute name
|
||||
choices = [(f, display_text(f, fltr)) for f, fltr in order_by]
|
||||
fields = {fltr.name: f for f, fltr in order_by}
|
||||
return OrderingFilter(choices=choices, fields=fields)
|
||||
|
||||
def get_order_by(self, order_choice):
|
||||
re_ordering_field = re.compile(r'(?P<inverse>\-?)(?P<field>.*)')
|
||||
m = re.match(re_ordering_field, order_choice)
|
||||
inverted = m.group('inverse')
|
||||
filter_api_name = m.group('field')
|
||||
# opts.order_by = True
|
||||
order_by = filters.items()
|
||||
|
||||
_filter = self.filters.get(filter_api_name, None)
|
||||
|
||||
if _filter and filter_api_name != _filter.name:
|
||||
return [inverted + _filter.name]
|
||||
return [order_choice]
|
||||
fields = [(fltr.name, f) for f, fltr in order_by]
|
||||
labels = {f: display_text(f, fltr) for f, fltr in order_by}
|
||||
return OrderingFilter(fields=fields, field_labels=labels)
|
||||
|
||||
@classmethod
|
||||
def filters_for_model(cls, model, opts):
|
||||
|
|
|
@ -9,6 +9,11 @@ from django.db.models.fields import FieldDoesNotExist
|
|||
from django.db.models.fields.related import ForeignObjectRel
|
||||
from django.utils import six, timezone
|
||||
|
||||
try:
|
||||
from django.forms.utils import pretty_name
|
||||
except ImportError: # Django 1.8
|
||||
from django.forms.forms import pretty_name
|
||||
|
||||
from .compat import remote_field, remote_model
|
||||
from .exceptions import FieldLookupError
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
from __future__ import absolute_import
|
||||
from __future__ import unicode_literals
|
||||
|
||||
from collections import OrderedDict
|
||||
from datetime import date, time, timedelta, datetime
|
||||
import mock
|
||||
import warnings
|
||||
|
@ -41,6 +42,7 @@ from django_filters.filters import (
|
|||
BaseInFilter,
|
||||
BaseRangeFilter,
|
||||
UUIDFilter,
|
||||
OrderingFilter,
|
||||
LOOKUP_TYPES)
|
||||
|
||||
from tests.models import Book, User
|
||||
|
@ -994,3 +996,104 @@ class BaseRangeFilterTests(TestCase):
|
|||
f = NumberInFilter()
|
||||
f.filter(qs, [1, 2])
|
||||
qs.filter.assert_called_once_with(None__range=[1, 2])
|
||||
|
||||
|
||||
class OrderingFilterTests(TestCase):
|
||||
def test_default_field(self):
|
||||
f = OrderingFilter()
|
||||
field = f.field
|
||||
self.assertIsInstance(field, forms.ChoiceField)
|
||||
|
||||
def test_filtering(self):
|
||||
qs = mock.Mock(spec=['order_by'])
|
||||
f = OrderingFilter()
|
||||
f.filter(qs, ['a', 'b'])
|
||||
qs.order_by.assert_called_once_with('a', 'b')
|
||||
|
||||
def test_filtering_descending(self):
|
||||
qs = mock.Mock(spec=['order_by'])
|
||||
f = OrderingFilter()
|
||||
f.filter(qs, ['-a'])
|
||||
qs.order_by.assert_called_once_with('-a')
|
||||
|
||||
def test_filtering_with_fields(self):
|
||||
qs = mock.Mock(spec=['order_by'])
|
||||
f = OrderingFilter(fields={'a': 'b'})
|
||||
f.filter(qs, ['b', '-b'])
|
||||
qs.order_by.assert_called_once_with('a', '-a')
|
||||
|
||||
def test_filtering_skipped_with_none_value(self):
|
||||
qs = mock.Mock(spec=['order_by'])
|
||||
f = OrderingFilter()
|
||||
result = f.filter(qs, None)
|
||||
self.assertEqual(qs, result)
|
||||
|
||||
def test_choices_unaltered(self):
|
||||
# provided 'choices' should not be altered when 'fields' is present
|
||||
f = OrderingFilter(
|
||||
choices=(('a', 'A'), ('b', 'B')),
|
||||
fields=(('a', 'c'), ('b', 'd')),
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(f.field.choices, (
|
||||
('a', 'A'),
|
||||
('b', 'B'),
|
||||
))
|
||||
|
||||
def test_choices_from_fields(self):
|
||||
f = OrderingFilter(
|
||||
fields=(('a', 'c'), ('b', 'd')),
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(f.field.choices, (
|
||||
('c', 'C'),
|
||||
('-c', 'C (descending)'),
|
||||
('d', 'D'),
|
||||
('-d', 'D (descending)'),
|
||||
))
|
||||
|
||||
def test_field_labels(self):
|
||||
f = OrderingFilter(
|
||||
fields=(('a', 'c'), ('b', 'd')),
|
||||
field_labels={'a': 'foo'},
|
||||
)
|
||||
|
||||
self.assertSequenceEqual(f.field.choices, (
|
||||
('c', 'foo'),
|
||||
('-c', 'foo (descending)'),
|
||||
('d', 'D'),
|
||||
('-d', 'D (descending)'),
|
||||
))
|
||||
|
||||
def test_normalize_fields(self):
|
||||
f = OrderingFilter.normalize_fields
|
||||
O = OrderedDict
|
||||
|
||||
self.assertIn('a', f({'a': 'b'}))
|
||||
|
||||
self.assertEqual(
|
||||
f(O([('a', 'b'), ('c', 'd')])),
|
||||
O([('a', 'b'), ('c', 'd')])
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
f([('a', 'b'), ('c', 'd')]),
|
||||
O([('a', 'b'), ('c', 'd')])
|
||||
)
|
||||
|
||||
self.assertEqual(
|
||||
f(['a', 'b']),
|
||||
O([('a', 'a'), ('b', 'b')])
|
||||
)
|
||||
|
||||
with self.assertRaises(AssertionError) as ctx:
|
||||
f(None)
|
||||
self.assertEqual(str(ctx.exception), "'fields' must be an iterable (e.g., a list, tuple, or mapping).")
|
||||
|
||||
with self.assertRaises(AssertionError) as ctx:
|
||||
f([('a', 'b', 'c')])
|
||||
self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.")
|
||||
|
||||
with self.assertRaises(AssertionError) as ctx:
|
||||
f([0, 1, 2])
|
||||
self.assertEqual(str(ctx.exception), "'fields' must contain strings or (field name, param name) pairs.")
|
||||
|
|
|
@ -575,17 +575,6 @@ class FilterSetOrderingTests(TestCase):
|
|||
# user_ids = list(User.objects.all().values_list('pk', flat=True))
|
||||
self.qs = User.objects.all().order_by('id')
|
||||
|
||||
def test_ordering_when_unbound(self):
|
||||
class F(FilterSet):
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'status']
|
||||
order_by = ['status']
|
||||
|
||||
f = F(queryset=self.qs)
|
||||
self.assertQuerysetEqual(
|
||||
f.qs, ['carl', 'alex', 'jacob', 'aaron'], lambda o: o.username)
|
||||
|
||||
def test_ordering(self):
|
||||
class F(FilterSet):
|
||||
class Meta:
|
||||
|
@ -725,29 +714,6 @@ class FilterSetOrderingTests(TestCase):
|
|||
self.assertQuerysetEqual(
|
||||
f.qs, ['jacob', 'carl', 'alex', 'aaron'], lambda o: o.username)
|
||||
|
||||
def test_custom_ordering(self):
|
||||
|
||||
class F(FilterSet):
|
||||
debug = True
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = ['username', 'status']
|
||||
order_by = ['username', 'status']
|
||||
|
||||
def get_order_by(self, order_choice):
|
||||
if order_choice == 'status':
|
||||
return ['status', 'username']
|
||||
return super(F, self).get_order_by(order_choice)
|
||||
|
||||
f = F({'o': 'username'}, queryset=self.qs)
|
||||
self.assertQuerysetEqual(
|
||||
f.qs, ['aaron', 'alex', 'carl', 'jacob'], lambda o: o.username)
|
||||
|
||||
f = F({'o': 'status'}, queryset=self.qs)
|
||||
self.assertQuerysetEqual(
|
||||
f.qs, ['carl', 'alex', 'aaron', 'jacob'], lambda o: o.username)
|
||||
|
||||
|
||||
class FilterSetTogetherTests(TestCase):
|
||||
|
||||
|
|
Loading…
Reference in New Issue