Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Rework 'lookup types' handling into LookupChoiceFilter #851

Merged
merged 9 commits into from
Jul 13, 2018
46 changes: 28 additions & 18 deletions django_filters/fields.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,13 @@
from django.utils.translation import ugettext_lazy as _

from .conf import settings
from .constants import EMPTY_VALUES
from .utils import handle_timezone
from .widgets import (
BaseCSVWidget,
CSVWidget,
DateRangeWidget,
LookupTypeWidget,
LookupChoiceWidget,
RangeWidget
)

Expand Down Expand Up @@ -79,32 +80,41 @@ def __init__(self, *args, **kwargs):
super().__init__(fields, *args, **kwargs)


class Lookup(namedtuple('Lookup', ('value', 'lookup_type'))):
# python nature is test __len__ on tuple types for boolean check
def __len__(self):
if not self.value:
return 0
return 2
class Lookup(namedtuple('Lookup', ('value', 'lookup_expr'))):
def __new__(cls, value, lookup_expr):
if value in EMPTY_VALUES or lookup_expr in EMPTY_VALUES:
raise ValueError(
"Empty values ([], (), {}, '', None) are not "
"valid Lookup arguments. Return None instead."
)

return super().__new__(cls, value, lookup_expr)


class LookupTypeField(forms.MultiValueField):
class LookupChoiceField(forms.MultiValueField):
default_error_messages = {
'lookup_required': _('Select a lookup.'),
}

def __init__(self, field, lookup_choices, *args, **kwargs):
fields = (
field,
forms.ChoiceField(choices=lookup_choices)
)
defaults = {
'widgets': [f.widget for f in fields],
}
widget = LookupTypeWidget(**defaults)
empty_label = kwargs.pop('empty_label', settings.EMPTY_CHOICE_LABEL)
fields = (field, ChoiceField(choices=lookup_choices, empty_label=empty_label))
widget = LookupChoiceWidget(widgets=[f.widget for f in fields])
kwargs['widget'] = widget
kwargs['help_text'] = field.help_text
super().__init__(fields, *args, **kwargs)

def compress(self, data_list):
if len(data_list) == 2:
return Lookup(value=data_list[0], lookup_type=data_list[1] or 'exact')
return Lookup(value=None, lookup_type='exact')
value, lookup_expr = data_list
if value not in EMPTY_VALUES:
if lookup_expr not in EMPTY_VALUES:
return Lookup(value=value, lookup_expr=lookup_expr)
else:
raise forms.ValidationError(
self.error_messages['lookup_required'],
code='lookup_required')
return None


class IsoDateTimeField(forms.DateTimeField):
Expand Down
145 changes: 106 additions & 39 deletions django_filters/filters.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@
from django import forms
from django.db.models import Q
from django.db.models.constants import LOOKUP_SEP
from django.db.models.sql.constants import QUERY_TERMS
from django.forms.utils import pretty_name
from django.utils.itercompat import is_iterable
from django.utils.timezone import now
Expand All @@ -19,15 +18,14 @@
DateRangeField,
DateTimeRangeField,
IsoDateTimeField,
Lookup,
LookupTypeField,
LookupChoiceField,
ModelChoiceField,
ModelMultipleChoiceField,
MultipleChoiceField,
RangeField,
TimeRangeField
)
from .utils import label_for_filter
from .utils import get_model_field, label_for_filter

__all__ = [
'AllValuesFilter',
Expand All @@ -46,6 +44,7 @@
'DurationFilter',
'Filter',
'IsoDateTimeFilter',
'LookupChoiceFilter',
'ModelChoiceFilter',
'ModelMultipleChoiceFilter',
'MultipleChoiceFilter',
Expand All @@ -61,9 +60,6 @@
]


LOOKUP_TYPES = sorted(QUERY_TERMS)


class Filter(object):
creation_counter = 0
field_class = forms.Field
Expand All @@ -83,6 +79,12 @@ def __init__(self, field_name=None, lookup_expr='exact', *, label=None,
self.creation_counter = Filter.creation_counter
Filter.creation_counter += 1

# TODO: remove assertion in 2.1
assert not isinstance(self.lookup_expr, (type(None), list)), \
"The `lookup_expr` argument no longer accepts `None` or a list of " \
"expressions. Use the `LookupChoiceFilter` instead. See: " \
"https://django-filter.readthedocs.io/en/master/guide/migration.html"

def get_method(self, qs):
"""Return filter method based on whether we're excluding
or simply filtering.
Expand Down Expand Up @@ -133,45 +135,16 @@ def field(self):
if settings.DISABLE_HELP_TEXT:
field_kwargs.pop('help_text', None)

if (self.lookup_expr is None or
isinstance(self.lookup_expr, (list, tuple))):

lookup = []

for x in LOOKUP_TYPES:
if isinstance(x, (list, tuple)) and len(x) == 2:
choice = (x[0], x[1])
else:
choice = (x, x)

if self.lookup_expr is None:
lookup.append(choice)
else:
if isinstance(x, (list, tuple)) and len(x) == 2:
if x[0] in self.lookup_expr:
lookup.append(choice)
else:
if x in self.lookup_expr:
lookup.append(choice)

self._field = LookupTypeField(
self.field_class(**field_kwargs), lookup,
required=field_kwargs['required'], label=self.label)
else:
self._field = self.field_class(label=self.label, **field_kwargs)
self._field = self.field_class(label=self.label, **field_kwargs)
return self._field

def filter(self, qs, value):
if isinstance(value, Lookup):
lookup = str(value.lookup_type)
value = value.value
else:
lookup = self.lookup_expr
if value in EMPTY_VALUES:
return qs
if self.distinct:
qs = qs.distinct()
qs = self.get_method(qs)(**{'%s__%s' % (self.field_name, lookup): value})
lookup = '%s__%s' % (self.field_name, self.lookup_expr)
qs = self.get_method(qs)(**{lookup: value})
return qs


Expand Down Expand Up @@ -562,6 +535,100 @@ def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)


class LookupChoiceFilter(Filter):
"""
A combined filter that allows users to select the lookup expression from a dropdown.

* ``lookup_choices`` is an optional argument that accepts multiple input
formats, and is ultimately normlized as the choices used in the lookup
dropdown. See ``.get_lookup_choices()`` for more information.

* ``field_class`` is an optional argument that allows you to set the inner
form field class used to validate the value. Default: ``forms.CharField``

ex::

price = django_filters.LookupChoiceFilter(
field_class=forms.DecimalField,
lookup_choices=[
('exact', 'Equals'),
('gt', 'Greater than'),
('lt', 'Less than'),
]
)

"""
field_class = forms.CharField
outer_class = LookupChoiceField

def __init__(self, field_name=None, lookup_choices=None, field_class=None, **kwargs):
self.empty_label = kwargs.pop('empty_label', settings.EMPTY_CHOICE_LABEL)

super(LookupChoiceFilter, self).__init__(field_name=field_name, **kwargs)

self.lookup_choices = lookup_choices
if field_class is not None:
self.field_class = field_class

@classmethod
def normalize_lookup(cls, lookup):
"""
Normalize the lookup into a tuple of ``(lookup expression, display value)``

If the ``lookup`` is already a tuple, the tuple is not altered.
If the ``lookup`` is a string, a tuple is returned with the lookup
expression used as the basis for the display value.

ex::

>>> LookupChoiceFilter.normalize_lookup(('exact', 'Equals'))
('exact', 'Equals')

>>> LookupChoiceFilter.normalize_lookup('has_key')
('has_key', 'Has key')

"""
if isinstance(lookup, str):
return (lookup, pretty_name(lookup))
return (lookup[0], lookup[1])

def get_lookup_choices(self):
"""
Get the lookup choices in a format suitable for ``django.forms.ChoiceField``.
If the filter is initialized with ``lookup_choices``, this value is normalized
and passed to the underlying ``LookupChoiceField``. If no choices are provided,
they are generated from the corresponding model field's registered lookups.
"""
lookups = self.lookup_choices
if lookups is None:
field = get_model_field(self.model, self.field_name)
lookups = field.get_lookups()

return [self.normalize_lookup(l) for l in lookups]

@property
def field(self):
if not hasattr(self, '_field'):
inner_field = super().field
lookups = self.get_lookup_choices()

self._field = self.outer_class(
inner_field, lookups,
label=self.label,
empty_label=self.empty_label,
required=self.extra['required'],
)

return self._field

def filter(self, qs, lookup):
if not lookup:
return super(LookupChoiceFilter, self).filter(qs, None)

self.lookup_expr = lookup.lookup_expr
return super(LookupChoiceFilter, self).filter(qs, lookup.value)


class OrderingFilter(BaseCSVFilter, ChoiceFilter):
"""
Enable queryset ordering. As an extension of ``ChoiceFilter`` it accepts
Expand Down
2 changes: 1 addition & 1 deletion django_filters/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -141,7 +141,7 @@ class DateRangeWidget(RangeWidget):
suffixes = ['after', 'before']


class LookupTypeWidget(SuffixedMultiWidget):
class LookupChoiceWidget(SuffixedMultiWidget):
suffixes = [None, 'lookup']

def decompress(self, value):
Expand Down
8 changes: 8 additions & 0 deletions docs/guide/migration.txt
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,14 @@ a fully forards-compatible migration release. Please review the below list of
changes and update your code accordingly.


``Filter.lookup_expr`` list form removed (`#851`__)
---------------------------------------------------
__ https://github.com/carltongibson/django-filter/pull/851

The ``Filter.lookup_expr`` argument no longer accepts ``None`` or a list of
expressions. Use the :ref:`LookupChoiceFilter <lookup-choice-filter>` instead.


FilterSet ``Meta.together`` option removed (`#791`__)
-----------------------------------------------------
__ https://github.com/carltongibson/django-filter/pull/791
Expand Down
25 changes: 25 additions & 0 deletions docs/ref/filters.txt
Original file line number Diff line number Diff line change
Expand Up @@ -624,6 +624,31 @@ database. So if in the DB for the given field you have values of 5, 7, and 9
each of those is present as an option. This is similar to the default behavior
of the admin.

.. _lookup-choice-filter:

``LookupChoiceFilter``
~~~~~~~~~~~~~~~~~~~~~~

A combined filter that allows users to select the lookup expression from a dropdown.

* ``lookup_choices`` is an optional argument that accepts multiple input
formats, and is ultimately normlized as the choices used in the lookup
dropdown. See ``.get_lookup_choices()`` for more information.

* ``field_class`` is an optional argument that allows you to set the inner
form field class used to validate the value. Default: ``forms.CharField``

ex::

price = django_filters.LookupChoiceFilter(
field_class=forms.DecimalField,
lookup_choices=[
('exact', 'Equals'),
('gt', 'Greater than'),
('lt', 'Less than'),
]
)

.. _base-in-filter:

``BaseInFilter``
Expand Down
Loading