Repository: carltongibson/django-filter

Issue:
Build fails against Django (pre-2.1) master
Hello,

Just as a heads up, your build is failing against the latest version of master in the Django repo, because they removed `from django.db.models.sql.constants import QUERY_TERMS`.

Apologies if it's a bit too early for that or if you already know it.


Discussion:
Hi @axnsan12. Never too early. 😀

If you want to put together a PR for this I'd be good to see. 👍
I don't really understand this module enough for that, I just noticed the problem in tests for a module I'm working on. 

From what I can tell, the enumeration is gone for good from django and django-filter seems to non-trivially rely on it.

Resolving patch:
diff --git a/django_filters/fields.py b/django_filters/fields.py
--- a/django_filters/fields.py
+++ b/django_filters/fields.py
@@ -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
 )
 
@@ -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):
diff --git a/django_filters/filters.py b/django_filters/filters.py
--- a/django_filters/filters.py
+++ b/django_filters/filters.py
@@ -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
@@ -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',
@@ -46,6 +44,7 @@
     'DurationFilter',
     'Filter',
     'IsoDateTimeFilter',
+    'LookupChoiceFilter',
     'ModelChoiceFilter',
     'ModelMultipleChoiceFilter',
     'MultipleChoiceFilter',
@@ -61,9 +60,6 @@
 ]
 
 
-LOOKUP_TYPES = sorted(QUERY_TERMS)
-
-
 class Filter(object):
     creation_counter = 0
     field_class = forms.Field
@@ -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.
@@ -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
 
 
@@ -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
diff --git a/django_filters/widgets.py b/django_filters/widgets.py
--- a/django_filters/widgets.py
+++ b/django_filters/widgets.py
@@ -141,7 +141,7 @@ class DateRangeWidget(RangeWidget):
     suffixes = ['after', 'before']
 
 
-class LookupTypeWidget(SuffixedMultiWidget):
+class LookupChoiceWidget(SuffixedMultiWidget):
     suffixes = [None, 'lookup']
 
     def decompress(self, value):


Test patch:
diff --git a/tests/test_fields.py b/tests/test_fields.py
--- a/tests/test_fields.py
+++ b/tests/test_fields.py
@@ -13,7 +13,7 @@
     DateTimeRangeField,
     IsoDateTimeField,
     Lookup,
-    LookupTypeField,
+    LookupChoiceField,
     RangeField,
     TimeRangeField
 )
@@ -24,18 +24,21 @@ def to_d(float_value):
     return decimal.Decimal('%.2f' % float_value)
 
 
-class LookupBoolTests(TestCase):
-    def test_lookup_true(self):
-        self.assertTrue(Lookup(True, 'exact'))
-        self.assertTrue(Lookup(1, 'exact'))
-        self.assertTrue(Lookup('1', 'exact'))
-        self.assertTrue(Lookup(datetime.now(), 'exact'))
+class LookupTests(TestCase):
+    def test_empty_attrs(self):
+        with self.assertRaisesMessage(ValueError, ''):
+            Lookup(None, None)
 
-    def test_lookup_false(self):
-        self.assertFalse(Lookup(False, 'exact'))
-        self.assertFalse(Lookup(0, 'exact'))
-        self.assertFalse(Lookup('', 'exact'))
-        self.assertFalse(Lookup(None, 'exact'))
+        with self.assertRaisesMessage(ValueError, ''):
+            Lookup('', '')
+
+    def test_empty_value(self):
+        with self.assertRaisesMessage(ValueError, ''):
+            Lookup('', 'exact')
+
+    def test_empty_lookup_expr(self):
+        with self.assertRaisesMessage(ValueError, ''):
+            Lookup('Value', '')
 
 
 class RangeFieldTests(TestCase):
@@ -102,26 +105,29 @@ def test_clean(self):
             slice(time(10, 15, 0), time(12, 30, 0)))
 
 
-class LookupTypeFieldTests(TestCase):
+class LookupChoiceFieldTests(TestCase):
 
     def test_field(self):
         inner = forms.DecimalField()
-        f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')])
+        f = LookupChoiceField(inner, [('gt', 'gt'), ('lt', 'lt')])
         self.assertEqual(len(f.fields), 2)
 
     def test_clean(self):
         inner = forms.DecimalField()
-        f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')], required=False)
+        f = LookupChoiceField(inner, [('gt', 'gt'), ('lt', 'lt')], required=False)
         self.assertEqual(
             f.clean(['12.34', 'lt']),
             Lookup(to_d(12.34), 'lt'))
         self.assertEqual(
             f.clean([]),
-            Lookup(value=None, lookup_type='exact'))
+            None)
+
+        with self.assertRaisesMessage(forms.ValidationError, 'Select a lookup.'):
+            f.clean(['12.34', ''])
 
     def test_render_used_html5(self):
         inner = forms.DecimalField()
-        f = LookupTypeField(inner, [('gt', 'gt'), ('lt', 'lt')])
+        f = LookupChoiceField(inner, [('gt', 'gt'), ('lt', 'lt')], empty_label=None)
         self.assertHTMLEqual(f.widget.render('price', ''), """
             <input type="number" step="any" name="price" />
             <select name="price_lookup">
diff --git a/tests/test_filtering.py b/tests/test_filtering.py
--- a/tests/test_filtering.py
+++ b/tests/test_filtering.py
@@ -16,6 +16,7 @@
     DateRangeFilter,
     DateTimeFromToRangeFilter,
     DurationFilter,
+    LookupChoiceFilter,
     ModelChoiceFilter,
     ModelMultipleChoiceFilter,
     MultipleChoiceFilter,
@@ -706,56 +707,6 @@ class Meta:
         f = F({'price': 10}, queryset=Book.objects.all())
         self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
 
-    def test_filtering_with_single_lookup_expr(self):
-        class F(FilterSet):
-            price = NumberFilter(lookup_expr='lt')
-
-            class Meta:
-                model = Book
-                fields = ['price']
-
-        f = F({'price': 16}, queryset=Book.objects.all().order_by('title'))
-        self.assertQuerysetEqual(
-            f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title)
-
-    def test_filtering_with_single_lookup_expr_dictionary(self):
-        class F(FilterSet):
-            class Meta:
-                model = Book
-                fields = {'price': ['lt']}
-
-        f = F({'price__lt': 16}, queryset=Book.objects.all().order_by('title'))
-        self.assertQuerysetEqual(
-            f.qs, ['Ender\'s Game', 'Rainbow Six'], lambda o: o.title)
-
-    def test_filtering_with_multiple_lookup_exprs(self):
-        class F(FilterSet):
-            price = NumberFilter(lookup_expr=['lt', 'gt'])
-
-            class Meta:
-                model = Book
-                fields = ['price']
-
-        qs = Book.objects.all()
-        f = F({'price': '15', 'price_lookup': 'lt'}, queryset=qs)
-        self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
-        f = F({'price': '15', 'price_lookup': 'lt'})
-        self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
-        f = F({'price': '', 'price_lookup': 'lt'})
-        self.assertQuerysetEqual(f.qs,
-                                 ['Ender\'s Game', 'Rainbow Six', 'Snowcrash'],
-                                 lambda o: o.title, ordered=False)
-
-        class F(FilterSet):
-            price = NumberFilter(lookup_expr=['lt', 'gt', 'exact'])
-
-            class Meta:
-                model = Book
-                fields = ['price']
-
-        f = F({'price': '15'})
-        self.assertQuerysetEqual(f.qs, ['Rainbow Six'], lambda o: o.title)
-
 
 class RangeFilterTests(TestCase):
 
@@ -1667,6 +1618,64 @@ class Meta:
         self.assertQuerysetEqual(f.qs, [a.pk], lambda o: o.pk)
 
 
+class LookupChoiceFilterTests(TestCase):
+
+    class BookFilter(FilterSet):
+        price = LookupChoiceFilter(lookup_choices=['lt', 'gt'], field_class=forms.DecimalField)
+
+        class Meta:
+            model = Book
+            fields = ['price']
+
+    @classmethod
+    def setUpTestData(cls):
+        Book.objects.create(title="Ender's Game", price='10.0',
+                            average_rating=4.7999999999999998)
+        Book.objects.create(title="Rainbow Six", price='15.0',
+                            average_rating=4.5999999999999996)
+        Book.objects.create(title="Snowcrash", price='20.0',
+                            average_rating=4.2999999999999998)
+
+    def test_filtering(self):
+        F = self.BookFilter
+
+        f = F({'price': '15', 'price_lookup': 'lt'})
+        self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
+        f = F({'price': '15', 'price_lookup': 'lt'})
+        self.assertQuerysetEqual(f.qs, ['Ender\'s Game'], lambda o: o.title)
+        f = F({'price': '', 'price_lookup': 'lt'})
+        self.assertTrue(f.is_valid())
+        self.assertQuerysetEqual(f.qs,
+                                 ['Ender\'s Game', 'Rainbow Six', 'Snowcrash'],
+                                 lambda o: o.title, ordered=False)
+        f = F({'price': '15'})
+        self.assertFalse(f.is_valid())
+        self.assertQuerysetEqual(f.qs,
+                                 ['Ender\'s Game', 'Rainbow Six', 'Snowcrash'],
+                                 lambda o: o.title, ordered=False)
+
+    def test_inner_field_class_validation(self):
+        f = self.BookFilter({'price': 'asdf', 'price_lookup': 'lt'})
+        self.assertFalse(f.is_valid())
+        self.assertEqual(f.errors, {
+            'price': ['Enter a number.'],
+        })
+
+    def test_lookup_choices_validation(self):
+        f = self.BookFilter({'price': '1', 'price_lookup': 'asdf'})
+        self.assertFalse(f.is_valid())
+        self.assertEqual(f.errors, {
+            'price': ['Select a valid choice. asdf is not one of the available choices.'],
+        })
+
+    def test_lookup_omitted(self):
+        f = self.BookFilter({'price': '1'})
+        self.assertFalse(f.is_valid())
+        self.assertEqual(f.errors, {
+            'price': ['Select a lookup.'],
+        })
+
+
 # use naive datetimes, as pytz is required to perform
 # date lookups when timezones are involved.
 @override_settings(USE_TZ=False)
diff --git a/tests/test_filters.py b/tests/test_filters.py
--- a/tests/test_filters.py
+++ b/tests/test_filters.py
@@ -15,12 +15,11 @@
     DateRangeField,
     DateTimeRangeField,
     Lookup,
-    LookupTypeField,
+    LookupChoiceField,
     RangeField,
     TimeRangeField
 )
 from django_filters.filters import (
-    LOOKUP_TYPES,
     AllValuesFilter,
     BaseCSVFilter,
     BaseInFilter,
@@ -35,6 +34,7 @@
     DateTimeFromToRangeFilter,
     DurationFilter,
     Filter,
+    LookupChoiceFilter,
     ModelChoiceFilter,
     ModelMultipleChoiceFilter,
     MultipleChoiceFilter,
@@ -94,24 +94,18 @@ def test_field_with_single_lookup_expr(self):
         field = f.field
         self.assertIsInstance(field, forms.Field)
 
-    def test_field_with_none_lookup_expr(self):
-        f = Filter(lookup_expr=None)
-        field = f.field
-        self.assertIsInstance(field, LookupTypeField)
-        choice_field = field.fields[1]
-        self.assertEqual(len(choice_field.choices), len(LOOKUP_TYPES))
+    def test_field_with_lookup_types_removal(self):
+        msg = (
+            "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 test_field_with_lookup_expr_and_exlusion(self):
-        f = Filter(lookup_expr=None, exclude=True)
-        field = f.field
-        self.assertIsInstance(field, LookupTypeField)
+        with self.assertRaisesMessage(AssertionError, msg):
+            Filter(lookup_expr=[])
 
-    def test_field_with_list_lookup_expr(self):
-        f = Filter(lookup_expr=('istartswith', 'iendswith'))
-        field = f.field
-        self.assertIsInstance(field, LookupTypeField)
-        choice_field = field.fields[1]
-        self.assertEqual(len(choice_field.choices), 2)
+        with self.assertRaisesMessage(AssertionError, msg):
+            Filter(lookup_expr=None)
 
     def test_field_params(self):
         with mock.patch.object(Filter, 'field_class',
@@ -176,28 +170,6 @@ def test_filtering_skipped_with_none_value(self):
         self.assertListEqual(qs.method_calls, [])
         self.assertEqual(qs, result)
 
-    def test_filtering_with_list_value(self):
-        qs = mock.Mock(spec=['filter'])
-        f = Filter(field_name='somefield', lookup_expr=['some_lookup_expr'])
-        result = f.filter(qs, Lookup('value', 'some_lookup_expr'))
-        qs.filter.assert_called_once_with(somefield__some_lookup_expr='value')
-        self.assertNotEqual(qs, result)
-
-    def test_filtering_skipped_with_list_value_with_blank(self):
-        qs = mock.Mock()
-        f = Filter(field_name='somefield', lookup_expr=['some_lookup_expr'])
-        result = f.filter(qs, Lookup('', 'some_lookup_expr'))
-        self.assertListEqual(qs.method_calls, [])
-        self.assertEqual(qs, result)
-
-    def test_filtering_skipped_with_list_value_with_blank_lookup(self):
-        return  # Now field is required to provide valid lookup_expr if it provides any
-        qs = mock.Mock(spec=['filter'])
-        f = Filter(field_name='somefield', lookup_expr=None)
-        result = f.filter(qs, Lookup('value', ''))
-        qs.filter.assert_called_once_with(somefield__exact='value')
-        self.assertNotEqual(qs, result)
-
     def test_filter_using_method(self):
         qs = mock.NonCallableMock(spec=[])
         method = mock.Mock()
@@ -214,33 +186,6 @@ def test_filtering_uses_distinct(self):
         self.assertNotEqual(qs, result)
 
 
-class CustomFilterWithBooleanCheckTests(TestCase):
-
-    def setUp(self):
-        super().setUp()
-
-        class CustomTestFilter(Filter):
-            def filter(self_, qs, value):
-                if not value:
-                    return qs
-                return super().filter(qs, value)
-
-        self.test_filter_class = CustomTestFilter
-
-    def test_lookup_false(self):
-        qs = mock.Mock(spec=['filter'])
-        f = self.test_filter_class(field_name='somefield')
-        result = f.filter(qs, Lookup('', 'exact'))
-        self.assertEqual(qs, result)
-
-    def test_lookup_true(self):
-        qs = mock.Mock(spec=['filter'])
-        f = self.test_filter_class(field_name='somefield')
-        result = f.filter(qs, Lookup('somesearch', 'exact'))
-        qs.filter.assert_called_once_with(somefield__exact='somesearch')
-        self.assertNotEqual(qs, result)
-
-
 class CharFilterTests(TestCase):
 
     def test_default_field(self):
@@ -1209,48 +1154,79 @@ def test_empty_value_in_choices(self):
         ])
 
 
-class LookupTypesTests(TestCase):
-    def test_custom_lookup_exprs(self):
-        filters.LOOKUP_TYPES = [
+class LookupChoiceFilterTests(TestCase):
+
+    def test_normalize_lookup_no_display_label(self):
+        # display label has underscores replaced and is capitalized
+        display_label = LookupChoiceFilter.normalize_lookup('has_key')
+        self.assertEqual(display_label, ('has_key', 'Has key'))
+
+    def test_normalize_lookup_with_display_label(self):
+        # display label is not transformed if provided
+        display_label = LookupChoiceFilter.normalize_lookup(('equal', 'equals'))
+        self.assertEqual(display_label, ('equal', 'equals'))
+
+    def test_lookup_choices_default(self):
+        # Lookup choices should default to the model field's registered lookups
+        f = LookupChoiceFilter(field_name='username', lookup_choices=None)
+        f.model = User
+
+        choice_field = f.field.fields[1]
+        self.assertEqual(
+            len(choice_field.choices),
+            len(User._meta.get_field('username').get_lookups()) + 1
+        )
+
+        field_choices = dict(choice_field.choices)
+        self.assertEqual(field_choices['exact'], 'Exact')
+        self.assertEqual(field_choices['startswith'], 'Startswith')
+
+    def test_lookup_choices_list(self):
+        f = LookupChoiceFilter(field_name='username', lookup_choices=[
+            'exact',
+            'startswith',
+            'has_key'
+        ])
+
+        choice_field = f.field.fields[1]
+        self.assertEqual(list(choice_field.choices), [
+            ('', '---------'),
+            ('exact', 'Exact'),
+            ('startswith', 'Startswith'),
+            ('has_key', 'Has key'),
+        ])
+
+    def test_lookup_choices_pairs(self):
+        f = LookupChoiceFilter(field_name='username', lookup_choices=[
+            ('exact', 'Is equal to'),
+            ('startswith', 'Starts with'),
+        ])
+
+        choice_field = f.field.fields[1]
+        self.assertEqual(list(choice_field.choices), [
             ('', '---------'),
             ('exact', 'Is equal to'),
-            ('not_exact', 'Is not equal to'),
-            ('lt', 'Lesser than'),
-            ('gt', 'Greater than'),
-            ('gte', 'Greater than or equal to'),
-            ('lte', 'Lesser than or equal to'),
             ('startswith', 'Starts with'),
-            ('endswith', 'Ends with'),
-            ('contains', 'Contains'),
-            ('not_contains', 'Does not contain'),
-        ]
+        ])
 
-        f = Filter(lookup_expr=None)
-        field = f.field
-        choice_field = field.fields[1]
-        all_choices = choice_field.choices
-
-        self.assertIsInstance(field, LookupTypeField)
-        self.assertEqual(all_choices, filters.LOOKUP_TYPES)
-        self.assertEqual(all_choices[1][0], 'exact')
-        self.assertEqual(all_choices[1][1], 'Is equal to')
-
-        custom_f = Filter(lookup_expr=('endswith', 'not_contains'))
-        custom_field = custom_f.field
-        custom_choice_field = custom_field.fields[1]
-        my_custom_choices = custom_choice_field.choices
-
-        available_lookup_exprs = [
-            ('endswith', 'Ends with'),
-            ('not_contains', 'Does not contain'),
-        ]
+    def test_lookup_choices_empty_label_default(self):
+        f = LookupChoiceFilter(field_name='username', lookup_choices=[])
+
+        choice_field = f.field.fields[1]
+        self.assertEqual(list(choice_field.choices), [('', '---------')])
 
-        self.assertIsInstance(custom_field, LookupTypeField)
-        self.assertEqual(my_custom_choices, available_lookup_exprs)
-        self.assertEqual(my_custom_choices[0][0], 'endswith')
-        self.assertEqual(my_custom_choices[0][1], 'Ends with')
-        self.assertEqual(my_custom_choices[1][0], 'not_contains')
-        self.assertEqual(my_custom_choices[1][1], 'Does not contain')
+    def test_lookup_choices_empty_label_disabled(self):
+        f = LookupChoiceFilter(field_name='username', empty_label=None, lookup_choices=[])
+
+        choice_field = f.field.fields[1]
+        self.assertEqual(list(choice_field.choices), [])
+
+    def test_filtering(self):
+        qs = mock.Mock(spec=['filter'])
+        f = LookupChoiceFilter(field_name='somefield', lookup_choices=['some_lookup_expr'])
+        result = f.filter(qs, Lookup('value', 'some_lookup_expr'))
+        qs.filter.assert_called_once_with(somefield__some_lookup_expr='value')
+        self.assertNotEqual(qs, result)
 
 
 class CSVFilterTests(TestCase):
diff --git a/tests/test_widgets.py b/tests/test_widgets.py
--- a/tests/test_widgets.py
+++ b/tests/test_widgets.py
@@ -6,7 +6,7 @@
     BooleanWidget,
     CSVWidget,
     LinkWidget,
-    LookupTypeWidget,
+    LookupChoiceWidget,
     QueryArrayWidget,
     RangeWidget,
     SuffixedMultiWidget
@@ -17,11 +17,11 @@ class LookupTypeWidgetTests(TestCase):
 
     def test_widget_requires_field(self):
         with self.assertRaises(TypeError):
-            LookupTypeWidget()
+            LookupChoiceWidget()
 
     def test_widget_render(self):
         widgets = [TextInput(), Select(choices=(('a', 'a'), ('b', 'b')))]
-        w = LookupTypeWidget(widgets)
+        w = LookupChoiceWidget(widgets)
         self.assertHTMLEqual(w.render('price', ''), """
             <input name="price" type="text" />
             <select name="price_lookup">