Code

Ticket #17091: 17091.changelist-lookup-filters-untangling.diff

File 17091.changelist-lookup-filters-untangling.diff, 25.5 KB (added by julien, 3 years ago)
Line 
1diff --git a/django/contrib/admin/filters.py b/django/contrib/admin/filters.py
2index ed98a9e..abe0e1b 100644
3--- a/django/contrib/admin/filters.py
4+++ b/django/contrib/admin/filters.py
5@@ -13,13 +13,14 @@ from django.utils.encoding import smart_unicode
6 from django.utils.translation import ugettext_lazy as _
7 
8 from django.contrib.admin.util import (get_model_from_relation,
9-    reverse_field_path, get_limit_choices_to_from_path)
10+    reverse_field_path, get_limit_choices_to_from_path,
11+    prepare_lookup_value_for_queryset_filtering)
12 
13 class ListFilter(object):
14     title = None  # Human-readable title to appear in the right sidebar.
15 
16     def __init__(self, request, params, model, model_admin):
17-        self.params = params
18+        self.used_params = {}
19         if self.title is None:
20             raise ImproperlyConfigured(
21                 "The list filter '%s' does not specify "
22@@ -43,15 +44,13 @@ class ListFilter(object):
23         """
24         raise NotImplementedError
25 
26-    def used_params(self):
27+    def used_param_names(self):
28         """
29         Return a list of parameters to consume from the change list
30         querystring.
31         """
32         raise NotImplementedError
33 
34-
35-
36 class SimpleListFilter(ListFilter):
37     # The parameter that should be used in the query string for that filter.
38     parameter_name = None
39@@ -67,6 +66,11 @@ class SimpleListFilter(ListFilter):
40         if lookup_choices is None:
41             lookup_choices = ()
42         self.lookup_choices = list(lookup_choices)
43+        if self.parameter_name in params:
44+            self.used_params[self.parameter_name] = params.get(self.parameter_name)
45+            del params[self.parameter_name]
46+        else:
47+            self.used_params[self.parameter_name] = None
48 
49     def has_output(self):
50         return len(self.lookup_choices) > 0
51@@ -76,7 +80,7 @@ class SimpleListFilter(ListFilter):
52         Returns the value given in the query string for this filter,
53         if any. Returns None otherwise.
54         """
55-        return self.params.get(self.parameter_name, None)
56+        return self.used_params[self.parameter_name]
57 
58     def lookups(self, request, model_admin):
59         """
60@@ -84,7 +88,7 @@ class SimpleListFilter(ListFilter):
61         """
62         raise NotImplementedError
63 
64-    def used_params(self):
65+    def used_param_names(self):
66         return [self.parameter_name]
67 
68     def choices(self, cl):
69@@ -112,14 +116,17 @@ class FieldListFilter(ListFilter):
70         self.field_path = field_path
71         self.title = getattr(field, 'verbose_name', field_path)
72         super(FieldListFilter, self).__init__(request, params, model, model_admin)
73+        for p in self.used_param_names():
74+            if p in params:
75+                self.used_params[p] = (
76+                    prepare_lookup_value_for_queryset_filtering(p, params[p]))
77+                del params[p]
78 
79     def has_output(self):
80         return True
81 
82     def queryset(self, request, queryset):
83-        for p in self.used_params():
84-            if p in self.params:
85-                return queryset.filter(**{p: self.params[p]})
86+        return queryset.filter(**self.used_params)
87 
88     @classmethod
89     def register(cls, test, list_filter_class, take_priority=False):
90@@ -144,20 +151,20 @@ class FieldListFilter(ListFilter):
91 
92 class RelatedFieldListFilter(FieldListFilter):
93     def __init__(self, field, request, params, model, model_admin, field_path):
94-        super(RelatedFieldListFilter, self).__init__(
95-            field, request, params, model, model_admin, field_path)
96         other_model = get_model_from_relation(field)
97-        if hasattr(field, 'verbose_name'):
98-            self.lookup_title = field.verbose_name
99-        else:
100-            self.lookup_title = other_model._meta.verbose_name
101         rel_name = other_model._meta.pk.name
102-        self.lookup_kwarg = '%s__%s__exact' % (self.field_path, rel_name)
103-        self.lookup_kwarg_isnull = '%s__isnull' % (self.field_path)
104+        self.lookup_kwarg = '%s__%s__exact' % (field_path, rel_name)
105+        self.lookup_kwarg_isnull = '%s__isnull' % (field_path)
106         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
107         self.lookup_val_isnull = request.GET.get(
108                                       self.lookup_kwarg_isnull, None)
109         self.lookup_choices = field.get_choices(include_blank=False)
110+        super(RelatedFieldListFilter, self).__init__(
111+            field, request, params, model, model_admin, field_path)
112+        if hasattr(field, 'verbose_name'):
113+            self.lookup_title = field.verbose_name
114+        else:
115+            self.lookup_title = other_model._meta.verbose_name
116         self.title = self.lookup_title
117 
118     def has_output(self):
119@@ -169,7 +176,7 @@ class RelatedFieldListFilter(FieldListFilter):
120             extra = 0
121         return len(self.lookup_choices) + extra > 1
122 
123-    def used_params(self):
124+    def used_param_names(self):
125         return [self.lookup_kwarg, self.lookup_kwarg_isnull]
126 
127     def choices(self, cl):
128@@ -206,14 +213,14 @@ FieldListFilter.register(lambda f: (
129 
130 class BooleanFieldListFilter(FieldListFilter):
131     def __init__(self, field, request, params, model, model_admin, field_path):
132-        super(BooleanFieldListFilter, self).__init__(field,
133-            request, params, model, model_admin, field_path)
134-        self.lookup_kwarg = '%s__exact' % self.field_path
135-        self.lookup_kwarg2 = '%s__isnull' % self.field_path
136+        self.lookup_kwarg = '%s__exact' % field_path
137+        self.lookup_kwarg2 = '%s__isnull' % field_path
138         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
139         self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
140+        super(BooleanFieldListFilter, self).__init__(field,
141+            request, params, model, model_admin, field_path)
142 
143-    def used_params(self):
144+    def used_param_names(self):
145         return [self.lookup_kwarg, self.lookup_kwarg2]
146 
147     def choices(self, cl):
148@@ -243,12 +250,12 @@ FieldListFilter.register(lambda f: isinstance(f,
149 
150 class ChoicesFieldListFilter(FieldListFilter):
151     def __init__(self, field, request, params, model, model_admin, field_path):
152+        self.lookup_kwarg = '%s__exact' % field_path
153+        self.lookup_val = request.GET.get(self.lookup_kwarg)
154         super(ChoicesFieldListFilter, self).__init__(
155             field, request, params, model, model_admin, field_path)
156-        self.lookup_kwarg = '%s__exact' % self.field_path
157-        self.lookup_val = request.GET.get(self.lookup_kwarg)
158 
159-    def used_params(self):
160+    def used_param_names(self):
161         return [self.lookup_kwarg]
162 
163     def choices(self, cl):
164@@ -269,25 +276,19 @@ FieldListFilter.register(lambda f: bool(f.choices), ChoicesFieldListFilter)
165 
166 class DateFieldListFilter(FieldListFilter):
167     def __init__(self, field, request, params, model, model_admin, field_path):
168-        super(DateFieldListFilter, self).__init__(
169-            field, request, params, model, model_admin, field_path)
170-
171-        self.field_generic = '%s__' % self.field_path
172+        self.field_generic = '%s__' % field_path
173         self.date_params = dict([(k, v) for k, v in params.items()
174                                  if k.startswith(self.field_generic)])
175-
176         today = datetime.date.today()
177         one_week_ago = today - datetime.timedelta(days=7)
178         today_str = str(today)
179-        if isinstance(self.field, models.DateTimeField):
180+        if isinstance(field, models.DateTimeField):
181             today_str += ' 23:59:59'
182-
183-        self.lookup_kwarg_year = '%s__year' % self.field_path
184-        self.lookup_kwarg_month = '%s__month' % self.field_path
185-        self.lookup_kwarg_day = '%s__day' % self.field_path
186-        self.lookup_kwarg_past_7_days_gte = '%s__gte' % self.field_path
187-        self.lookup_kwarg_past_7_days_lte = '%s__lte' % self.field_path
188-
189+        self.lookup_kwarg_year = '%s__year' % field_path
190+        self.lookup_kwarg_month = '%s__month' % field_path
191+        self.lookup_kwarg_day = '%s__day' % field_path
192+        self.lookup_kwarg_past_7_days_gte = '%s__gte' % field_path
193+        self.lookup_kwarg_past_7_days_lte = '%s__lte' % field_path
194         self.links = (
195             (_('Any date'), {}),
196             (_('Today'), {
197@@ -307,25 +308,15 @@ class DateFieldListFilter(FieldListFilter):
198                 self.lookup_kwarg_year: str(today.year),
199             }),
200         )
201+        super(DateFieldListFilter, self).__init__(
202+            field, request, params, model, model_admin, field_path)
203 
204-    def used_params(self):
205+    def used_param_names(self):
206         return [
207             self.lookup_kwarg_year, self.lookup_kwarg_month, self.lookup_kwarg_day,
208             self.lookup_kwarg_past_7_days_gte, self.lookup_kwarg_past_7_days_lte
209         ]
210 
211-    def queryset(self, request, queryset):
212-        """
213-        Override the default behaviour since there can be multiple query
214-        string parameters used for the same date filter (e.g. year + month).
215-        """
216-        query_dict = {}
217-        for p in self.used_params():
218-            if p in self.params:
219-                query_dict[p] = self.params[p]
220-        if len(query_dict):
221-            return queryset.filter(**query_dict)
222-
223     def choices(self, cl):
224         for title, param_dict in self.links:
225             yield {
226@@ -344,13 +335,11 @@ FieldListFilter.register(
227 # more appropriate, and the AllValuesFieldListFilter won't get used for it.
228 class AllValuesFieldListFilter(FieldListFilter):
229     def __init__(self, field, request, params, model, model_admin, field_path):
230-        super(AllValuesFieldListFilter, self).__init__(
231-            field, request, params, model, model_admin, field_path)
232-        self.lookup_kwarg = self.field_path
233-        self.lookup_kwarg_isnull = '%s__isnull' % self.field_path
234+        self.lookup_kwarg = field_path
235+        self.lookup_kwarg_isnull = '%s__isnull' % field_path
236         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
237         self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull, None)
238-        parent_model, reverse_path = reverse_field_path(model, self.field_path)
239+        parent_model, reverse_path = reverse_field_path(model, field_path)
240         queryset = parent_model._default_manager.all()
241         # optional feature: limit choices base on existing relationships
242         # queryset = queryset.complex_filter(
243@@ -360,8 +349,10 @@ class AllValuesFieldListFilter(FieldListFilter):
244 
245         self.lookup_choices = queryset.distinct(
246             ).order_by(field.name).values_list(field.name, flat=True)
247+        super(AllValuesFieldListFilter, self).__init__(
248+            field, request, params, model, model_admin, field_path)
249 
250-    def used_params(self):
251+    def used_param_names(self):
252         return [self.lookup_kwarg, self.lookup_kwarg_isnull]
253 
254     def choices(self, cl):
255diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
256index 7204a12..eee2294 100644
257--- a/django/contrib/admin/util.py
258+++ b/django/contrib/admin/util.py
259@@ -11,6 +11,31 @@ from django.utils.encoding import force_unicode, smart_unicode, smart_str
260 from django.utils.translation import ungettext
261 from django.core.urlresolvers import reverse
262 
263+def lookup_path_needs_distinct(opts, lookup_path):
264+    field_name = lookup_path.split('__', 1)[0]
265+    field = opts.get_field_by_name(field_name)[0]
266+    if ((hasattr(field, 'rel') and
267+         isinstance(field.rel, models.ManyToManyRel)) or
268+        (isinstance(field, models.related.RelatedObject) and
269+         not field.field.unique)):
270+         return True
271+    return False
272+
273+def prepare_lookup_value_for_queryset_filtering(key, value):
274+    """
275+    Returns a lookup value prepared to be used in queryset filtering.
276+    """
277+    # if key ends with __in, split parameter into separate values
278+    if key.endswith('__in'):
279+        value = value.split(',')
280+    # if key ends with __isnull, special case '' and false
281+    if key.endswith('__isnull'):
282+        if value.lower() in ('', 'false'):
283+            value = False
284+        else:
285+            value = True
286+    return value
287+
288 
289 def quote(s):
290     """
291diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
292index 616b249..3bd14db 100644
293--- a/django/contrib/admin/views/main.py
294+++ b/django/contrib/admin/views/main.py
295@@ -1,6 +1,6 @@
296 import operator
297 
298-from django.core.exceptions import SuspiciousOperation
299+from django.core.exceptions import SuspiciousOperation, ImproperlyConfigured
300 from django.core.paginator import InvalidPage
301 from django.db import models
302 from django.utils.datastructures import SortedDict
303@@ -10,7 +10,8 @@ from django.utils.http import urlencode
304 
305 from django.contrib.admin import FieldListFilter
306 from django.contrib.admin.options import IncorrectLookupParameters
307-from django.contrib.admin.util import quote, get_fields_from_path
308+from django.contrib.admin.util import (quote, get_fields_from_path,
309+    lookup_path_needs_distinct, prepare_lookup_value_for_queryset_filtering)
310 
311 # Changelist settings
312 ALL_VAR = 'all'
313@@ -28,14 +29,6 @@ IGNORED_PARAMS = (
314 # Text to display within change-list table cells if the value is blank.
315 EMPTY_CHANGELIST_VALUE = ugettext_lazy('(None)')
316 
317-def field_needs_distinct(field):
318-    if ((hasattr(field, 'rel') and
319-         isinstance(field.rel, models.ManyToManyRel)) or
320-        (isinstance(field, models.related.RelatedObject) and
321-         not field.field.unique)):
322-         return True
323-    return False
324-
325 
326 class ChangeList(object):
327     def __init__(self, request, model, list_display, list_display_links,
328@@ -84,14 +77,33 @@ class ChangeList(object):
329         self.title = title % force_unicode(self.opts.verbose_name)
330         self.pk_attname = self.lookup_opts.pk.attname
331 
332-    def get_filters(self, request, use_distinct=False):
333+    def get_filters(self, request):
334+        lookup_params = self.params.copy() # a dictionary of the query string
335+        use_distinct = False
336+
337+        # Remove all the parameters that are globally and systematically
338+        # ignored.
339+        for ignored in IGNORED_PARAMS:
340+            if ignored in lookup_params:
341+                del lookup_params[ignored]
342+
343+        # Normalize the types of keys
344+        for key, value in lookup_params.items():
345+            if not isinstance(key, str):
346+                # 'key' will be used as a keyword argument later, so Python
347+                # requires it to be a string.
348+                del lookup_params[key]
349+                lookup_params[smart_str(key)] = value
350+
351+            if not self.model_admin.lookup_allowed(key, value):
352+                raise SuspiciousOperation("Filtering by %s not allowed" % key)
353+
354         filter_specs = []
355-        cleaned_params, use_distinct = self.get_lookup_params(use_distinct)
356         if self.list_filter:
357             for list_filter in self.list_filter:
358                 if callable(list_filter):
359                     # This is simply a custom list filter class.
360-                    spec = list_filter(request, cleaned_params,
361+                    spec = list_filter(request, lookup_params,
362                         self.model, self.model_admin)
363                 else:
364                     field_path = None
365@@ -106,11 +118,27 @@ class ChangeList(object):
366                     if not isinstance(field, models.Field):
367                         field_path = field
368                         field = get_fields_from_path(self.model, field_path)[-1]
369-                    spec = field_list_filter_class(field, request, cleaned_params,
370+                    spec = field_list_filter_class(field, request, lookup_params,
371                         self.model, self.model_admin, field_path=field_path)
372+                    # Check if we need to use distinct()
373+                    use_distinct = (use_distinct or
374+                                   lookup_path_needs_distinct(self.lookup_opts,
375+                                                              field_path))
376                 if spec and spec.has_output():
377                     filter_specs.append(spec)
378-        return filter_specs, bool(filter_specs)
379+
380+        # At this point, all the parameters used by the various ListFilters
381+        # have been removed from lookup_params, which now only contains other
382+        # parameters passed via the query string. We now loop through the
383+        # remaining parameters both to ensure that all the parameters are valid
384+        # fields and to determine if at least one of them needs distinct().
385+        for key, value in lookup_params.items():
386+            lookup_params[key] = prepare_lookup_value_for_queryset_filtering(
387+                                    key, value)
388+            use_distinct = (use_distinct or
389+                            lookup_path_needs_distinct(self.lookup_opts, key))
390+
391+        return filter_specs, bool(filter_specs), lookup_params, use_distinct
392 
393     def get_query_string(self, new_params=None, remove=None):
394         if new_params is None: new_params = {}
395@@ -250,78 +278,34 @@ class ChangeList(object):
396                 ordering_fields[idx] = 'desc' if pfx == '-' else 'asc'
397         return ordering_fields
398 
399-    def get_lookup_params(self, use_distinct=False):
400-        lookup_params = self.params.copy() # a dictionary of the query string
401-
402-        for ignored in IGNORED_PARAMS:
403-            if ignored in lookup_params:
404-                del lookup_params[ignored]
405-
406-        for key, value in lookup_params.items():
407-            if not isinstance(key, str):
408-                # 'key' will be used as a keyword argument later, so Python
409-                # requires it to be a string.
410-                del lookup_params[key]
411-                lookup_params[smart_str(key)] = value
412-
413-            field = None
414-            if not use_distinct:
415-                # Check if it's a relationship that might return more than one
416-                # instance
417-                field_name = key.split('__', 1)[0]
418-                try:
419-                    field = self.lookup_opts.get_field_by_name(field_name)[0]
420-                    use_distinct = field_needs_distinct(field)
421-                except models.FieldDoesNotExist:
422-                    # It might be a custom NonFieldFilter
423-                    pass
424-
425-            # if key ends with __in, split parameter into separate values
426-            if key.endswith('__in'):
427-                value = value.split(',')
428-                lookup_params[key] = value
429-
430-            # if key ends with __isnull, special case '' and false
431-            if key.endswith('__isnull'):
432-                if value.lower() in ('', 'false'):
433-                    value = False
434-                else:
435-                    value = True
436-                lookup_params[key] = value
437-
438-            if field and not self.model_admin.lookup_allowed(key, value):
439-                raise SuspiciousOperation("Filtering by %s not allowed" % key)
440-
441-        return lookup_params, use_distinct
442-
443     def get_query_set(self, request):
444-        lookup_params, use_distinct = self.get_lookup_params(use_distinct=False)
445-        self.filter_specs, self.has_filters = self.get_filters(request, use_distinct)
446-
447         try:
448-            # First, let every list filter modify the qs and params to its
449-            # liking.
450+            # First, we collect all the declared list filters.
451+            (self.filter_specs, self.has_filters, remaining_lookup_params,
452+             use_distinct) = self.get_filters(request)
453+
454+            # Then, we let every list filter modify the qs to its liking.
455             qs = self.root_query_set
456             for filter_spec in self.filter_specs:
457                 new_qs = filter_spec.queryset(request, qs)
458                 if new_qs is not None:
459                     qs = new_qs
460-                    for param in filter_spec.used_params():
461-                        try:
462-                            del lookup_params[param]
463-                        except KeyError:
464-                            pass
465 
466-            # Then, apply the remaining lookup parameters from the query string
467-            # (i.e. those that haven't already been processed by the filters).
468-            qs = qs.filter(**lookup_params)
469+            # Finally, we apply the remaining lookup parameters from the query
470+            # string (i.e. those that haven't already been processed by the
471+            # filters).
472+            qs = qs.filter(**remaining_lookup_params)
473+        except (SuspiciousOperation, ImproperlyConfigured):
474+            # Allow certain types of errors to be re-raised as-is so that the
475+            # caller can treat them in a special way.
476+            raise
477         except Exception, e:
478-            # Naked except! Because we don't have any other way of validating
479-            # "lookup_params". They might be invalid if the keyword arguments
480-            # are incorrect, or if the values are not in the correct type, so
481-            # we might get FieldError, ValueError, ValicationError, or ? from a
482-            # custom field that raises yet something else when handed
483-            # impossible data.
484+            # Every other error is caught with a naked except, because we don't
485+            # have any other way of validating lookup parameters. They might be
486+            # invalid if the keyword arguments are incorrect, or if the values
487+            # are not in the correct type, so we might get FieldError,
488+            # ValueError, ValidationError, or ? from a custom field that raises
489+            # yet something else when handed impossible data.
490             raise IncorrectLookupParameters(e)
491 
492         # Use select_related() if one of the list_display options is a field
493@@ -365,9 +349,8 @@ class ChangeList(object):
494                 qs = qs.filter(reduce(operator.or_, or_queries))
495             if not use_distinct:
496                 for search_spec in orm_lookups:
497-                    field_name = search_spec.split('__', 1)[0]
498-                    f = self.lookup_opts.get_field_by_name(field_name)[0]
499-                    if field_needs_distinct(f):
500+                    if lookup_path_needs_distinct(self.lookup_opts,
501+                        search_spec):
502                         use_distinct = True
503                         break
504 
505diff --git a/tests/regressiontests/admin_filters/tests.py b/tests/regressiontests/admin_filters/tests.py
506index 28693ae..4988e57 100644
507--- a/tests/regressiontests/admin_filters/tests.py
508+++ b/tests/regressiontests/admin_filters/tests.py
509@@ -68,6 +68,14 @@ class DecadeListFilterWithQuerysetBasedLookups(DecadeListFilterWithTitleAndParam
510         if qs.filter(year__gte=2000, year__lte=2009).exists():
511             yield ('the 00s', "the 2000's")
512 
513+class DecadeListFilterParameterEndsWith__In(DecadeListFilter):
514+    title = 'publication decade'
515+    parameter_name = 'decade__in' # Ends with '__in"
516+
517+class DecadeListFilterParameterEndsWith__Isnull(DecadeListFilter):
518+    title = 'publication decade'
519+    parameter_name = 'decade__isnull' # Ends with '__isnull"
520+
521 class CustomUserAdmin(UserAdmin):
522     list_filter = ('books_authored', 'books_contributed')
523 
524@@ -97,6 +105,12 @@ class DecadeFilterBookAdminWithFailingQueryset(ModelAdmin):
525 class DecadeFilterBookAdminWithQuerysetBasedLookups(ModelAdmin):
526     list_filter = (DecadeListFilterWithQuerysetBasedLookups,)
527 
528+class DecadeFilterBookAdminParameterEndsWith__In(ModelAdmin):
529+    list_filter = (DecadeListFilterParameterEndsWith__In,)
530+
531+class DecadeFilterBookAdminParameterEndsWith__Isnull(ModelAdmin):
532+    list_filter = (DecadeListFilterParameterEndsWith__Isnull,)
533+
534 class ListFiltersTests(TestCase):
535 
536     def setUp(self):
537@@ -570,3 +584,44 @@ class ListFiltersTests(TestCase):
538         choices = list(filterspec.choices(changelist))
539         self.assertEqual(choices[2]['selected'], True)
540         self.assertEqual(choices[2]['query_string'], '?no=207')
541+
542+    def test_parameter_ends_with__in__or__isnull(self):
543+        """
544+        Ensure that a SimpleListFilter's parameter name is not mistaken for a
545+        model field if it ends with '__isnull' or '__in'.
546+        Refs #17091.
547+        """
548+
549+        # When it ends with '__in' -----------------------------------------
550+        modeladmin = DecadeFilterBookAdminParameterEndsWith__In(Book, site)
551+        request = self.request_factory.get('/', {'decade__in': 'the 90s'})
552+        changelist = self.get_changelist(request, Book, modeladmin)
553+
554+        # Make sure the correct queryset is returned
555+        queryset = changelist.get_query_set(request)
556+        self.assertEqual(list(queryset), [self.bio_book])
557+
558+        # Make sure the correct choice is selected
559+        filterspec = changelist.get_filters(request)[0][0]
560+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
561+        choices = list(filterspec.choices(changelist))
562+        self.assertEqual(choices[2]['display'], u'the 1990\'s')
563+        self.assertEqual(choices[2]['selected'], True)
564+        self.assertEqual(choices[2]['query_string'], '?decade__in=the+90s')
565+
566+        # When it ends with '__isnull' ---------------------------------------
567+        modeladmin = DecadeFilterBookAdminParameterEndsWith__Isnull(Book, site)
568+        request = self.request_factory.get('/', {'decade__isnull': 'the 90s'})
569+        changelist = self.get_changelist(request, Book, modeladmin)
570+
571+        # Make sure the correct queryset is returned
572+        queryset = changelist.get_query_set(request)
573+        self.assertEqual(list(queryset), [self.bio_book])
574+
575+        # Make sure the correct choice is selected
576+        filterspec = changelist.get_filters(request)[0][0]
577+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
578+        choices = list(filterspec.choices(changelist))
579+        self.assertEqual(choices[2]['display'], u'the 1990\'s')
580+        self.assertEqual(choices[2]['selected'], True)
581+        self.assertEqual(choices[2]['query_string'], '?decade__isnull=the+90s')
582\ No newline at end of file