From 270cd4e0938db0023e0cadfc48ca4b4a0b068d41 Mon Sep 17 00:00:00 2001
From: Jannis Leidel <jannis@leidel.info>
Date: Mon, 2 May 2011 18:58:29 +0200
Subject: [PATCH] Fixed #5833 -- Extended admin's FilterSpecs to be easier to override.

---
 django/contrib/admin/__init__.py                  |    4 +
 django/contrib/admin/filterspecs.py               |  497 +++++++++++++--------
 django/contrib/admin/templatetags/admin_list.py   |    2 +-
 django/contrib/admin/validation.py                |   47 ++-
 django/contrib/admin/views/main.py                |  106 +++--
 django/db/models/related.py                       |    2 +-
 docs/ref/contrib/admin/index.txt                  |  123 +++++-
 tests/regressiontests/admin_filterspecs/models.py |   16 +-
 tests/regressiontests/admin_filterspecs/tests.py  |  348 ++++++++++++---
 tests/regressiontests/modeladmin/tests.py         |   69 +++-
 10 files changed, 886 insertions(+), 328 deletions(-)

diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py
index f8e634e..3e69c37 100644
--- a/django/contrib/admin/__init__.py
+++ b/django/contrib/admin/__init__.py
@@ -4,6 +4,10 @@ from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
 from django.contrib.admin.options import StackedInline, TabularInline
 from django.contrib.admin.sites import AdminSite, site
+from django.contrib.admin.filterspecs import (ListFilterBase,
+        SimpleListFilter, FieldListFilter, BooleanFieldListFilter,
+        RelatedFieldListFilter, ChoicesFieldListFilter, DateFieldListFilter,
+        AllValuesFieldListFilter)
 
 
 def autodiscover():
diff --git a/django/contrib/admin/filterspecs.py b/django/contrib/admin/filterspecs.py
index 965b32b..a871c70 100644
--- a/django/contrib/admin/filterspecs.py
+++ b/django/contrib/admin/filterspecs.py
@@ -5,235 +5,351 @@ Filters are specified in models with the "list_filter" option.
 Each filter subclass knows how to display a filter for a field that passes a
 certain test -- e.g. being a DateField or ForeignKey.
 """
+import datetime
 
 from django.db import models
-from django.utils.encoding import smart_unicode, iri_to_uri
-from django.utils.translation import ugettext as _
-from django.utils.html import escape
-from django.utils.safestring import mark_safe
-from django.contrib.admin.util import get_model_from_relation, \
-    reverse_field_path, get_limit_choices_to_from_path
-import datetime
+from django.core.exceptions import ImproperlyConfigured
+from django.utils.encoding import smart_unicode
+from django.utils.translation import ugettext_lazy as _
+
+from django.contrib.admin.util import (get_model_from_relation,
+    reverse_field_path, get_limit_choices_to_from_path)
+
+class ListFilterBase(object):
+    title = None  # Human-readable title to appear in the right sidebar.
 
-class FilterSpec(object):
-    filter_specs = []
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None):
-        self.field = f
+    def __init__(self, request, params, model, model_admin):
         self.params = params
+        if self.title is None:
+            raise ImproperlyConfigured(
+                "The list filter '%s' does not specify "
+                "a 'title'." % self.__class__.__name__)
+
+    def has_output(self):
+        """
+        Returns True if some choices would be output for the filter.
+        """
+        raise NotImplementedError
+
+    def choices(self, cl):
+        """
+        Returns choices ready to be output in the template.
+        """
+        raise NotImplementedError
+
+    def queryset(self, request, queryset):
+        """
+        Returns the filtered queryset.
+        """
+        raise NotImplementedError
+
+    def used_params(self):
+        """
+        Return a list of parameters to consume from the change list
+        querystring.
+        """
+        raise NotImplementedError
+
+
+
+class SimpleListFilter(ListFilterBase):
+    # The parameter that should be used in the query string for that filter.
+    # Defaults to the title, slugified.
+    parameter_name = None
+
+    def __init__(self, request, params, model, model_admin):
+        super(SimpleListFilter, self).__init__(
+            request, params, model, model_admin)
+        if self.parameter_name is None:
+            raise ImproperlyConfigured(
+                "The list filter '%s' does not specify "
+                "a 'parameter_name'." % self.__class__.__name__)
+        self.lookup_choices = self.lookups(request)
+
+    def has_output(self):
+        return len(self.lookup_choices) > 0
+
+    def value(self):
+        """
+        Returns the value given in the query string for this filter,
+        if any. Returns None otherwise.
+        """
+        return self.params.get(self.parameter_name, None)
+
+    def lookups(self, request):
+        """
+        Must be overriden to return a list of tuples (value, verbose value)
+        """
+        raise NotImplementedError
+
+    def used_params(self):
+        return [self.parameter_name]
+
+    def choices(self, cl):
+        yield {
+            'selected': self.value() is None,
+            'query_string': cl.get_query_string({}, [self.parameter_name]),
+            'display': _('All'),
+        }
+        for lookup, title in self.lookup_choices:
+            yield {
+                'selected': self.value() == lookup,
+                'query_string': cl.get_query_string({
+                    self.parameter_name: lookup,
+                }, []),
+                'display': title,
+            }
+
+
+class FieldListFilter(ListFilterBase):
+    _field_list_filters = []
+    _take_priority_index = 0
+
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        self.field = field
         self.field_path = field_path
-        if field_path is None:
-            if isinstance(f, models.related.RelatedObject):
-                self.field_path = f.var_name
-            else:
-                self.field_path = f.name
-
-    def register(cls, test, factory):
-        cls.filter_specs.append((test, factory))
-    register = classmethod(register)
-
-    def create(cls, f, request, params, model, model_admin, field_path=None):
-        for test, factory in cls.filter_specs:
-            if test(f):
-                return factory(f, request, params, model, model_admin,
-                               field_path=field_path)
-    create = classmethod(create)
+        self.title = field_path
+        super(FieldListFilter, self).__init__(request, params, model, model_admin)
 
     def has_output(self):
         return True
 
-    def choices(self, cl):
-        raise NotImplementedError()
-
-    def title(self):
-        return self.field.verbose_name
-
-    def output(self, cl):
-        t = []
-        if self.has_output():
-            t.append(_(u'<h3>By %s:</h3>\n<ul>\n') % escape(self.title()))
-
-            for choice in self.choices(cl):
-                t.append(u'<li%s><a href="%s">%s</a></li>\n' % \
-                    ((choice['selected'] and ' class="selected"' or ''),
-                     iri_to_uri(choice['query_string']),
-                     choice['display']))
-            t.append('</ul>\n\n')
-        return mark_safe("".join(t))
-
-class RelatedFilterSpec(FilterSpec):
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None):
-        super(RelatedFilterSpec, self).__init__(
-            f, request, params, model, model_admin, field_path=field_path)
-
-        other_model = get_model_from_relation(f)
-        if isinstance(f, (models.ManyToManyField,
+    def queryset(self, request, queryset):
+        for p in self.used_params():
+            if p in self.params:
+                return queryset.filter(**{p: self.params[p]})
+
+    @classmethod
+    def register(cls, test, list_filter_class, take_priority=False):
+        if take_priority:
+            # This is to allow overriding the default filters for certain types
+            # of fields with some custom filters. The first found in the list
+            # is used in priority.
+            cls._field_list_filters.insert(
+                cls._take_priority_index, (test, list_filter_class))
+            cls._take_priority_index += 1
+        else:
+            cls._field_list_filters.append((test, list_filter_class))
+
+    @classmethod
+    def create(cls, field, request, params, model, model_admin, field_path):
+        for test, list_filter_class in cls._field_list_filters:
+            if not test(field):
+                continue
+            return list_filter_class(field, request, params,
+                model, model_admin, field_path=field_path)
+
+
+class RelatedFieldListFilter(FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        super(RelatedFieldListFilter, self).__init__(
+            field, request, params, model, model_admin, field_path)
+
+        other_model = get_model_from_relation(field)
+        if isinstance(field, (models.ManyToManyField,
                           models.related.RelatedObject)):
             # no direct field on this model, get name from other model
             self.lookup_title = other_model._meta.verbose_name
         else:
-            self.lookup_title = f.verbose_name # use field name
+            self.lookup_title = field.verbose_name # use field name
         rel_name = other_model._meta.pk.name
         self.lookup_kwarg = '%s__%s__exact' % (self.field_path, rel_name)
         self.lookup_kwarg_isnull = '%s__isnull' % (self.field_path)
         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
         self.lookup_val_isnull = request.GET.get(
                                       self.lookup_kwarg_isnull, None)
-        self.lookup_choices = f.get_choices(include_blank=False)
+        self.lookup_choices = field.get_choices(include_blank=False)
+        self.title = self.lookup_title
 
     def has_output(self):
-        if isinstance(self.field, models.related.RelatedObject) \
-           and self.field.field.null or hasattr(self.field, 'rel') \
-           and self.field.null:
+        if (isinstance(self.field, models.related.RelatedObject)
+                and self.field.field.null or hasattr(self.field, 'rel')
+                    and self.field.null):
             extra = 1
         else:
             extra = 0
         return len(self.lookup_choices) + extra > 1
 
-    def title(self):
-        return self.lookup_title
+    def used_params(self):
+        return [self.lookup_kwarg, self.lookup_kwarg_isnull]
 
     def choices(self, cl):
         from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
-        yield {'selected': self.lookup_val is None
-                           and not self.lookup_val_isnull,
-               'query_string': cl.get_query_string(
-                               {},
-                               [self.lookup_kwarg, self.lookup_kwarg_isnull]),
-               'display': _('All')}
+        yield {
+            'selected': self.lookup_val is None and not self.lookup_val_isnull,
+            'query_string': cl.get_query_string({},
+                [self.lookup_kwarg, self.lookup_kwarg_isnull]),
+            'display': _('All'),
+        }
         for pk_val, val in self.lookup_choices:
-            yield {'selected': self.lookup_val == smart_unicode(pk_val),
-                   'query_string': cl.get_query_string(
-                                   {self.lookup_kwarg: pk_val},
-                                   [self.lookup_kwarg_isnull]),
-                   'display': val}
-        if isinstance(self.field, models.related.RelatedObject) \
-           and self.field.field.null or hasattr(self.field, 'rel') \
-           and self.field.null:
-            yield {'selected': bool(self.lookup_val_isnull),
-                   'query_string': cl.get_query_string(
-                                   {self.lookup_kwarg_isnull: 'True'},
-                                   [self.lookup_kwarg]),
-                   'display': EMPTY_CHANGELIST_VALUE}
-
-FilterSpec.register(lambda f: (
+            yield {
+                'selected': self.lookup_val == smart_unicode(pk_val),
+                'query_string': cl.get_query_string({
+                    self.lookup_kwarg: pk_val,
+                }, [self.lookup_kwarg_isnull]),
+                'display': val,
+            }
+        if (isinstance(self.field, models.related.RelatedObject)
+                and self.field.field.null or hasattr(self.field, 'rel')
+                    and self.field.null):
+            yield {
+                'selected': bool(self.lookup_val_isnull),
+                'query_string': cl.get_query_string({
+                    self.lookup_kwarg_isnull: 'True',
+                }, [self.lookup_kwarg]),
+                'display': EMPTY_CHANGELIST_VALUE,
+            }
+
+FieldListFilter.register(lambda f: (
         hasattr(f, 'rel') and bool(f.rel) or
-        isinstance(f, models.related.RelatedObject)), RelatedFilterSpec)
-
-class BooleanFieldFilterSpec(FilterSpec):
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None):
-        super(BooleanFieldFilterSpec, self).__init__(f, request, params, model,
-                                                     model_admin,
-                                                     field_path=field_path)
+        isinstance(f, models.related.RelatedObject)), RelatedFieldListFilter)
+
+class BooleanFieldListFilter(FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        super(BooleanFieldListFilter, self).__init__(field,
+            request, params, model, model_admin, field_path)
         self.lookup_kwarg = '%s__exact' % self.field_path
         self.lookup_kwarg2 = '%s__isnull' % self.field_path
         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
         self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
 
-    def title(self):
-        return self.field.verbose_name
+    def used_params(self):
+        return [self.lookup_kwarg, self.lookup_kwarg2]
 
     def choices(self, cl):
-        for k, v in ((_('All'), None), (_('Yes'), '1'), (_('No'), '0')):
-            yield {'selected': self.lookup_val == v and not self.lookup_val2,
-                   'query_string': cl.get_query_string(
-                                   {self.lookup_kwarg: v},
-                                   [self.lookup_kwarg2]),
-                   'display': k}
+        for lookup, title in (
+                (None, _('All')),
+                ('1', _('Yes')),
+                ('0', _('No'))):
+            yield {
+                'selected': self.lookup_val == lookup and not self.lookup_val2,
+                'query_string': cl.get_query_string({
+                        self.lookup_kwarg: lookup,
+                    }, [self.lookup_kwarg2]),
+                'display': title,
+            }
         if isinstance(self.field, models.NullBooleanField):
-            yield {'selected': self.lookup_val2 == 'True',
-                   'query_string': cl.get_query_string(
-                                   {self.lookup_kwarg2: 'True'},
-                                   [self.lookup_kwarg]),
-                   'display': _('Unknown')}
-
-FilterSpec.register(lambda f: isinstance(f, models.BooleanField)
-                              or isinstance(f, models.NullBooleanField),
-                                 BooleanFieldFilterSpec)
-
-class ChoicesFilterSpec(FilterSpec):
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None):
-        super(ChoicesFilterSpec, self).__init__(f, request, params, model,
-                                                model_admin,
-                                                field_path=field_path)
+            yield {
+                'selected': self.lookup_val2 == 'True',
+                'query_string': cl.get_query_string({
+                        self.lookup_kwarg2: 'True',
+                    }, [self.lookup_kwarg]),
+                'display': _('Unknown'),
+            }
+
+FieldListFilter.register(lambda f: isinstance(f,
+    (models.BooleanField, models.NullBooleanField)), BooleanFieldListFilter)
+
+
+class ChoicesFieldListFilter(FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        super(ChoicesFieldListFilter, self).__init__(
+            field, request, params, model, model_admin, field_path)
         self.lookup_kwarg = '%s__exact' % self.field_path
-        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
+        self.lookup_val = request.GET.get(self.lookup_kwarg)
+
+    def used_params(self):
+        return [self.lookup_kwarg]
 
     def choices(self, cl):
-        yield {'selected': self.lookup_val is None,
-               'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
-               'display': _('All')}
-        for k, v in self.field.flatchoices:
-            yield {'selected': smart_unicode(k) == self.lookup_val,
-                    'query_string': cl.get_query_string(
-                                    {self.lookup_kwarg: k}),
-                    'display': v}
-
-FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
-
-class DateFieldFilterSpec(FilterSpec):
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None): 
-        super(DateFieldFilterSpec, self).__init__(f, request, params, model,
-                                                  model_admin,
-                                                  field_path=field_path)
+        yield {
+            'selected': self.lookup_val is None,
+            'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
+            'display': _('All')
+        }
+        for lookup, title in self.field.flatchoices:
+            yield {
+                'selected': smart_unicode(lookup) == self.lookup_val,
+                'query_string': cl.get_query_string({self.lookup_kwarg: lookup}),
+                'display': title,
+            }
+
+FieldListFilter.register(lambda f: bool(f.choices), ChoicesFieldListFilter)
+
+
+class DateFieldListFilter(FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        super(DateFieldListFilter, self).__init__(
+            field, request, params, model, model_admin, field_path)
 
         self.field_generic = '%s__' % self.field_path
-
         self.date_params = dict([(k, v) for k, v in params.items()
                                  if k.startswith(self.field_generic)])
 
         today = datetime.date.today()
         one_week_ago = today - datetime.timedelta(days=7)
-        today_str = isinstance(self.field, models.DateTimeField) \
-                    and today.strftime('%Y-%m-%d 23:59:59') \
-                    or today.strftime('%Y-%m-%d')
+        today_str = (isinstance(self.field, models.DateTimeField)
+                        and today.strftime('%Y-%m-%d 23:59:59')
+                        or today.strftime('%Y-%m-%d'))
+
+        self.lookup_kwarg_year = '%s__year' % self.field_path
+        self.lookup_kwarg_month = '%s__month' % self.field_path
+        self.lookup_kwarg_day = '%s__day' % self.field_path
+        self.lookup_kwarg_past_7_days_gte = '%s__gte' % self.field_path
+        self.lookup_kwarg_past_7_days_lte = '%s__lte' % self.field_path
 
         self.links = (
             (_('Any date'), {}),
-            (_('Today'), {'%s__year' % self.field_path: str(today.year),
-                       '%s__month' % self.field_path: str(today.month),
-                       '%s__day' % self.field_path: str(today.day)}),
-            (_('Past 7 days'), {'%s__gte' % self.field_path:
-                                    one_week_ago.strftime('%Y-%m-%d'),
-                             '%s__lte' % self.field_path: today_str}),
-            (_('This month'), {'%s__year' % self.field_path: str(today.year),
-                             '%s__month' % self.field_path: str(today.month)}),
-            (_('This year'), {'%s__year' % self.field_path: str(today.year)})
+            (_('Today'), {
+                self.lookup_kwarg_year: str(today.year),
+                self.lookup_kwarg_month: str(today.month),
+                self.lookup_kwarg_day: str(today.day),
+            }),
+            (_('Past 7 days'), {
+                self.lookup_kwarg_past_7_days_gte: one_week_ago.strftime('%Y-%m-%d'),
+                self.lookup_kwarg_past_7_days_lte: today_str,
+            }),
+            (_('This month'), {
+                self.lookup_kwarg_year: str(today.year),
+                self.lookup_kwarg_month: str(today.month),
+            }),
+            (_('This year'), {
+                self.lookup_kwarg_year: str(today.year),
+            }),
         )
 
-    def title(self):
-        return self.field.verbose_name
+    def used_params(self):
+        return [
+            self.lookup_kwarg_year, self.lookup_kwarg_month, self.lookup_kwarg_day,
+            self.lookup_kwarg_past_7_days_gte, self.lookup_kwarg_past_7_days_lte
+        ]
+
+    def queryset(self, request, queryset):
+        """
+        Override the default behaviour since there can be multiple query
+        string parameters used for the same date filter (e.g. year + month).
+        """
+        query_dict = {}
+        for p in self.used_params():
+            if p in self.params:
+                query_dict[p] = self.params[p]
+        if len(query_dict):
+            return queryset.filter(**query_dict)
 
     def choices(self, cl):
         for title, param_dict in self.links:
-            yield {'selected': self.date_params == param_dict,
-                   'query_string': cl.get_query_string(
-                                   param_dict,
-                                   [self.field_generic]),
-                   'display': title}
+            yield {
+                'selected': self.date_params == param_dict,
+                'query_string': cl.get_query_string(
+                    param_dict, [self.field_generic]),
+                'display': title,
+            }
 
-FilterSpec.register(lambda f: isinstance(f, models.DateField),
-                              DateFieldFilterSpec)
+FieldListFilter.register(
+    lambda f: isinstance(f, models.DateField), DateFieldListFilter)
 
 
 # This should be registered last, because it's a last resort. For example,
-# if a field is eligible to use the BooleanFieldFilterSpec, that'd be much
-# more appropriate, and the AllValuesFilterSpec won't get used for it.
-class AllValuesFilterSpec(FilterSpec):
-    def __init__(self, f, request, params, model, model_admin,
-                 field_path=None):
-        super(AllValuesFilterSpec, self).__init__(f, request, params, model,
-                                                  model_admin,
-                                                  field_path=field_path)
+# if a field is eligible to use the BooleanFieldListFilter, that'd be much
+# more appropriate, and the AllValuesFieldListFilter won't get used for it.
+class AllValuesFieldListFilter(FieldListFilter):
+    def __init__(self, field, request, params, model, model_admin, field_path):
+        super(AllValuesFieldListFilter, self).__init__(
+            field, request, params, model, model_admin, field_path)
         self.lookup_kwarg = self.field_path
         self.lookup_kwarg_isnull = '%s__isnull' % self.field_path
         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
-        self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull,
-                                                 None)
+        self.lookup_val_isnull = request.GET.get(self.lookup_kwarg_isnull, None)
         parent_model, reverse_path = reverse_field_path(model, self.field_path)
         queryset = parent_model._default_manager.all()
         # optional feature: limit choices base on existing relationships
@@ -242,38 +358,41 @@ class AllValuesFilterSpec(FilterSpec):
         limit_choices_to = get_limit_choices_to_from_path(model, field_path)
         queryset = queryset.filter(limit_choices_to)
 
-        self.lookup_choices = \
-            queryset.distinct().order_by(f.name).values_list(f.name, flat=True)
+        self.lookup_choices = queryset.distinct(
+            ).order_by(field.name).values_list(field.name, flat=True)
 
-    def title(self):
-        return self.field.verbose_name
+    def used_params(self):
+        return [self.lookup_kwarg, self.lookup_kwarg_isnull]
 
     def choices(self, cl):
         from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
-        yield {'selected': self.lookup_val is None
-                           and self.lookup_val_isnull is None,
-               'query_string': cl.get_query_string(
-                               {},
-                               [self.lookup_kwarg, self.lookup_kwarg_isnull]),
-               'display': _('All')}
+        yield {
+            'selected': (self.lookup_val is None
+                and self.lookup_val_isnull is None),
+            'query_string': cl.get_query_string({},
+                [self.lookup_kwarg, self.lookup_kwarg_isnull]),
+            'display': _('All'),
+        }
         include_none = False
-
         for val in self.lookup_choices:
             if val is None:
                 include_none = True
                 continue
             val = smart_unicode(val)
-
-            yield {'selected': self.lookup_val == val,
-                   'query_string': cl.get_query_string(
-                                   {self.lookup_kwarg: val},
-                                   [self.lookup_kwarg_isnull]),
-                   'display': val}
+            yield {
+                'selected': self.lookup_val == val,
+                'query_string': cl.get_query_string({
+                    self.lookup_kwarg: val,
+                }, [self.lookup_kwarg_isnull]),
+                'display': val,
+            }
         if include_none:
-            yield {'selected': bool(self.lookup_val_isnull),
-                    'query_string': cl.get_query_string(
-                                    {self.lookup_kwarg_isnull: 'True'},
-                                    [self.lookup_kwarg]),
-                    'display': EMPTY_CHANGELIST_VALUE}
-
-FilterSpec.register(lambda f: True, AllValuesFilterSpec)
+            yield {
+                'selected': bool(self.lookup_val_isnull),
+                'query_string': cl.get_query_string({
+                    self.lookup_kwarg_isnull: 'True',
+                }, [self.lookup_kwarg]),
+                'display': EMPTY_CHANGELIST_VALUE,
+            }
+
+FieldListFilter.register(lambda f: True, AllValuesFieldListFilter)
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index 5e199ce..b72c0be 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -319,7 +319,7 @@ def search_form(cl):
 
 @register.inclusion_tag('admin/filter.html')
 def admin_list_filter(cl, spec):
-    return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
+    return {'title': spec.title, 'choices' : list(spec.choices(cl))}
 
 @register.inclusion_tag('admin/actions.html', takes_context=True)
 def admin_actions(context):
diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py
index 159afa4..f989cf3 100644
--- a/django/contrib/admin/validation.py
+++ b/django/contrib/admin/validation.py
@@ -3,6 +3,7 @@ from django.db import models
 from django.db.models.fields import FieldDoesNotExist
 from django.forms.models import (BaseModelForm, BaseModelFormSet, fields_for_model,
     _get_foreign_key)
+from django.contrib.admin.filterspecs import ListFilterBase, FieldListFilter
 from django.contrib.admin.util import get_fields_from_path, NotRelationField
 from django.contrib.admin.options import (flatten_fieldsets, BaseModelAdmin,
     HORIZONTAL, VERTICAL)
@@ -54,15 +55,43 @@ def validate(cls, model):
     # list_filter
     if hasattr(cls, 'list_filter'):
         check_isseq(cls, 'list_filter', cls.list_filter)
-        for idx, fpath in enumerate(cls.list_filter):
-            try:
-                get_fields_from_path(model, fpath)
-            except (NotRelationField, FieldDoesNotExist), e:
-                raise ImproperlyConfigured(
-                    "'%s.list_filter[%d]' refers to '%s' which does not refer to a Field." % (
-                        cls.__name__, idx, fpath
-                    )
-                )
+        for idx, item in enumerate(cls.list_filter):
+            # There are three options for specifying a filter:
+            #   1: 'field' - a basic field filter, possibly w/ relationships (eg, 'field__rel')
+            #   2: ('field', SomeFieldListFilter) - a field-based list filter class
+            #   3: SomeListFilter - a non-field list filter class
+            if callable(item) and not isinstance(item, models.Field):
+                # If item is option 3, it should be a ListFilterBase...
+                if not issubclass(item, ListFilterBase):
+                    raise ImproperlyConfigured("'%s.list_filter[%d]' is '%s'"
+                            " which is not a descendant of ListFilterBase."
+                            % (cls.__name__, idx, item.__name__))
+                # ...  but not a FieldListFilter.
+                if issubclass(item, FieldListFilter):
+                    raise ImproperlyConfigured("'%s.list_filter[%d]' is '%s'"
+                            " which is of type FieldListFilter but is not"
+                            " associated with a field name."
+                            % (cls.__name__, idx, item.__name__))
+            else:
+                try:
+                    # Check for option #2 (tuple)
+                    field, list_filter_class = item
+                except (TypeError, ValueError):
+                    # item is option #1
+                    field = item
+                else:
+                    # item is option #2
+                    if not issubclass(list_filter_class, FieldListFilter):
+                        raise ImproperlyConfigured("'%s.list_filter[%d][1]'"
+                            " is '%s' which is not of type FieldListFilter."
+                            % (cls.__name__, idx, list_filter_class.__name__))
+                # Validate the field string
+                try:
+                    get_fields_from_path(model, field)
+                except (NotRelationField, FieldDoesNotExist):
+                    raise ImproperlyConfigured("'%s.list_filter[%d]' refers to '%s'"
+                            " which does not refer to a Field."
+                            % (cls.__name__, idx, field))
 
     # list_per_page = 100
     if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
index 0cfc43d..4413466 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -1,13 +1,15 @@
-from django.contrib.admin.filterspecs import FilterSpec
-from django.contrib.admin.options import IncorrectLookupParameters
-from django.contrib.admin.util import quote, get_fields_from_path
+import operator
+
 from django.core.exceptions import SuspiciousOperation
 from django.core.paginator import InvalidPage
 from django.db import models
 from django.utils.encoding import force_unicode, smart_str
 from django.utils.translation import ugettext, ugettext_lazy
 from django.utils.http import urlencode
-import operator
+
+from django.contrib.admin.filterspecs import FieldListFilter
+from django.contrib.admin.options import IncorrectLookupParameters
+from django.contrib.admin.util import quote, get_fields_from_path
 
 # The system will display a "Show all" link on the change list only if the
 # total result count is less than or equal to this setting.
@@ -23,6 +25,9 @@ TO_FIELD_VAR = 't'
 IS_POPUP_VAR = 'pop'
 ERROR_FLAG = 'e'
 
+IGNORED_PARAMS = (
+    ALL_VAR, ORDER_VAR, ORDER_TYPE_VAR, SEARCH_VAR, IS_POPUP_VAR, TO_FIELD_VAR)
+
 # Text to display within change-list table cells if the value is blank.
 EMPTY_CHANGELIST_VALUE = ugettext_lazy('(None)')
 
@@ -36,7 +41,10 @@ def field_needs_distinct(field):
 
 
 class ChangeList(object):
-    def __init__(self, request, model, list_display, list_display_links, list_filter, date_hierarchy, search_fields, list_select_related, list_per_page, list_editable, model_admin):
+    def __init__(self, request, model, list_display, list_display_links,
+            list_filter, date_hierarchy, search_fields, list_select_related,
+            list_per_page, list_editable, model_admin):
+        self.request = request
         self.model = model
         self.opts = model._meta
         self.lookup_opts = self.opts
@@ -72,18 +80,37 @@ class ChangeList(object):
         self.query = request.GET.get(SEARCH_VAR, '')
         self.query_set = self.get_query_set()
         self.get_results(request)
-        self.title = (self.is_popup and ugettext('Select %s') % force_unicode(self.opts.verbose_name) or ugettext('Select %s to change') % force_unicode(self.opts.verbose_name))
-        self.filter_specs, self.has_filters = self.get_filters(request)
+        if self.is_popup:
+            title = ugettext('Select %s')
+        else:
+            title = ugettext('Select %s to change')
+        self.title = title % force_unicode(self.opts.verbose_name)
         self.pk_attname = self.lookup_opts.pk.attname
 
-    def get_filters(self, request):
+    def get_filters(self, request, use_distinct=False):
         filter_specs = []
+        cleaned_params, use_distinct = self.get_lookup_params(use_distinct)
         if self.list_filter:
-            for filter_name in self.list_filter:
-                field = get_fields_from_path(self.model, filter_name)[-1]
-                spec = FilterSpec.create(field, request, self.params,
-                                         self.model, self.model_admin,
-                                         field_path=filter_name)
+            for list_filer in self.list_filter:
+                if callable(list_filer):
+                    # This is simply a custom list filter class.
+                    spec = list_filer(request, cleaned_params,
+                        self.model, self.model_admin)
+                else:
+                    field_path = None
+                    try:
+                        # This is custom FieldListFilter class for a given field.
+                        field, field_list_filter_class = list_filer
+                    except (TypeError, ValueError):
+                        # This is simply a field name, so use the default
+                        # FieldListFilter class that has been registered for
+                        # the type of the given field.
+                        field, field_list_filter_class = list_filer, FieldListFilter.create
+                    if not isinstance(field, models.Field):
+                        field_path = field
+                        field = get_fields_from_path(self.model, field_path)[-1]
+                    spec = field_list_filter_class(field, request, cleaned_params,
+                        self.model, self.model_admin, field_path=field_path)
                 if spec and spec.has_output():
                     filter_specs.append(spec)
         return filter_specs, bool(filter_specs)
@@ -175,14 +202,13 @@ class ChangeList(object):
             order_type = params[ORDER_TYPE_VAR]
         return order_field, order_type
 
-    def get_query_set(self):
-        use_distinct = False
-
-        qs = self.root_query_set
+    def get_lookup_params(self, use_distinct=False):
         lookup_params = self.params.copy() # a dictionary of the query string
-        for i in (ALL_VAR, ORDER_VAR, ORDER_TYPE_VAR, SEARCH_VAR, IS_POPUP_VAR, TO_FIELD_VAR):
-            if i in lookup_params:
-                del lookup_params[i]
+
+        for ignored in IGNORED_PARAMS:
+            if ignored in lookup_params:
+                del lookup_params[ignored]
+
         for key, value in lookup_params.items():
             if not isinstance(key, str):
                 # 'key' will be used as a keyword argument later, so Python
@@ -195,10 +221,11 @@ class ChangeList(object):
                 # instance
                 field_name = key.split('__', 1)[0]
                 try:
-                    f = self.lookup_opts.get_field_by_name(field_name)[0]
+                    field = self.lookup_opts.get_field_by_name(field_name)[0]
+                    use_distinct = field_needs_distinct(field)
                 except models.FieldDoesNotExist:
-                    raise IncorrectLookupParameters
-                use_distinct = field_needs_distinct(f)
+                    # It might be a custom NonFieldFilter
+                    pass
 
             # if key ends with __in, split parameter into separate values
             if key.endswith('__in'):
@@ -214,11 +241,28 @@ class ChangeList(object):
                 lookup_params[key] = value
 
             if not self.model_admin.lookup_allowed(key, value):
-                raise SuspiciousOperation(
-                    "Filtering by %s not allowed" % key
-                )
+                raise SuspiciousOperation("Filtering by %s not allowed" % key)
+
+        return lookup_params, use_distinct
+
+    def get_query_set(self):
+        lookup_params, use_distinct = self.get_lookup_params(use_distinct=False)
+        self.filter_specs, self.has_filters = self.get_filters(self.request, use_distinct)
+
+        # Let every list filter modify the qs and params to its liking
+        qs = self.root_query_set
+        for filter_spec in self.filter_specs:
+            new_qs = filter_spec.queryset(self.request, qs)
+            if new_qs is not None:
+                qs = new_qs
+                for param in filter_spec.used_params():
+                    try:
+                        del lookup_params[param]
+                    except KeyError:
+                        pass
 
-        # Apply lookup parameters from the query string.
+        # Apply the remaining lookup parameters from the query string (i.e.
+        # those that haven't already been processed by the filters).
         try:
             qs = qs.filter(**lookup_params)
         # Naked except! Because we don't have any other way of validating "params".
@@ -226,8 +270,8 @@ class ChangeList(object):
         # values are not in the correct type, so we might get FieldError, ValueError,
         # ValicationError, or ? from a custom field that raises yet something else
         # when handed impossible data.
-        except:
-            raise IncorrectLookupParameters
+        except Exception, e:
+            raise IncorrectLookupParameters(e)
 
         # Use select_related() if one of the list_display options is a field
         # with a relationship and the provided queryset doesn't already have
@@ -238,11 +282,11 @@ class ChangeList(object):
             else:
                 for field_name in self.list_display:
                     try:
-                        f = self.lookup_opts.get_field(field_name)
+                        field = self.lookup_opts.get_field(field_name)
                     except models.FieldDoesNotExist:
                         pass
                     else:
-                        if isinstance(f.rel, models.ManyToOneRel):
+                        if isinstance(field.rel, models.ManyToOneRel):
                             qs = qs.select_related()
                             break
 
diff --git a/django/db/models/related.py b/django/db/models/related.py
index 7734230..90995d7 100644
--- a/django/db/models/related.py
+++ b/django/db/models/related.py
@@ -27,7 +27,7 @@ class RelatedObject(object):
         as SelectField choices for this field.
 
         Analogue of django.db.models.fields.Field.get_choices, provided
-        initially for utilisation by RelatedFilterSpec.
+        initially for utilisation by RelatedFieldListFilter.
         """
         first_choice = include_blank and blank_choice or []
         queryset = self.model._default_manager.all()
diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
index 633c53f..31adf46 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -525,30 +525,113 @@ subclass::
 
 .. attribute:: ModelAdmin.list_filter
 
-    Set ``list_filter`` to activate filters in the right sidebar of the change
-    list page of the admin. This should be a list of field names, and each
-    specified field should be either a ``BooleanField``, ``CharField``,
-    ``DateField``, ``DateTimeField``, ``IntegerField`` or ``ForeignKey``.
-
-    This example, taken from the ``django.contrib.auth.models.User`` model,
-    shows how both ``list_display`` and ``list_filter`` work::
+    .. versionchanged:: 1.4
 
-        class UserAdmin(admin.ModelAdmin):
-            list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
-            list_filter = ('is_staff', 'is_superuser')
-
-    The above code results in an admin change list page that looks like this:
+    Set ``list_filter`` to activate filters in the right sidebar of the change
+    list page of the admin, as illustrated in the following screenshot:
 
         .. image:: _images/users_changelist.png
 
-    (This example also has ``search_fields`` defined. See below.)
-
-    .. versionadded:: 1.3
-
-    Fields in ``list_filter`` can also span relations using the ``__`` lookup::
-
-        class UserAdminWithLookup(UserAdmin):
-            list_filter = ('groups__name')
+    ``list_filter`` should be a list of elements, where each element should be
+    of one of the following types:
+
+        * a field name, where the specified field should be either a
+          ``BooleanField``, ``CharField``, ``DateField``, ``DateTimeField``,
+          ``IntegerField``, ``ForeignKey`` or ``ManyToManyField``, for example::
+
+              class PersonAdmin(ModelAdmin):
+                  list_filter = ('is_staff', 'company')
+
+          .. versionadded:: 1.3
+
+          Field names in ``list_filter`` can also span relations
+          using the ``__`` lookup, for example::
+
+              class PersonAdmin(UserAdmin):
+                  list_filter = ('company__name',)
+
+        * a class inheriting from :mod:`django.contrib.admin.SimpleListFilter`,
+          where you need to provide a few attributes and override a few
+          methods::
+
+              from django.db.models import Q
+              from django.utils.translation import ugettext_lazy as _
+
+              from django.contrib.admin import SimpleListFilter
+
+              class DecadeBornListFilter(SimpleListFilter):
+                  # Human-readable title which will be displayed in the
+                  # right sidebar just above the filter options.
+                  verbose_name = _('decade born')
+
+                  # Parameter for the filter that will be used in the url query.
+                  # Providing this attribute is optional. If it is not provided then a
+                  # slugified version of the title will automatically be used instead
+                  # (that is, 'decade-born' in this example).
+                  parameter_name = 'decade'
+
+                  def lookups(self, *args, **kwargs):
+                      """
+                      Returns a list of tuples. The first element in each tuple
+                      is the coded value for the option that will appear in the
+                      url query. The second element is the human-readable name
+                      for the option that will appear in the right sidebar.
+                      """
+                      return (
+                          ('80s', 'in the eighties'),
+                          ('other', 'other'),
+                      )
+
+                  def queryset(self, queryset, *args, **kwargs):
+                      """
+                      Returns the filtered queryset based on the value provided
+                      in the query string and retrievable via `value()`.
+                      """
+                      # Compare the requested value (either '80s' or 'other')
+                      # to decide how to filter the queryset.
+                      if self.value() == '80s':
+                          return queryset.filter(birthday__year__gte=1980,
+                                                 birthday__year__lte=1989)
+                      if self.value() == 'other':
+                          return queryset.filter(Q(year__lte=1979) |
+                                                 Q(year__gte=1990))
+
+              class PersonAdmin(ModelAdmin):
+                  list_filter = (DecadeBornListFilter,)
+
+          .. note::
+
+              As a convenience, the ``HttpRequest`` object is passed to the
+              filter's methods, for example::
+
+                  class AuthDecadeBornListFilter(DecadeBornListFilter):
+
+                      def lookups(self, request, *args, **kwargs):
+                          if request.user.is_authenticated():
+                              return (
+                                  ('80s', 'in the eighties'),
+                                  ('other', 'other'),
+                              )
+                          else:
+                              return (
+                                  ('90s', 'in the nineties'),
+                              )
+
+        * a tuple, where the first element is a field name and the second
+          element is a class inheriting from
+          :mod:`django.contrib.admin.FieldListFilter`, for example::
+
+              from django.contrib.admin import BooleanFieldListFilter
+
+              class PersonAdmin(ModelAdmin):
+                  list_filter = (
+                      ('is_staff', BooleanFieldListFilter),
+                  )
+
+          .. note::
+
+              The ``FieldListFilter`` API is currently considered internal
+              and prone to refactoring.
 
 .. attribute:: ModelAdmin.list_per_page
 
diff --git a/tests/regressiontests/admin_filterspecs/models.py b/tests/regressiontests/admin_filterspecs/models.py
index 5b284c7..80d54c7 100644
--- a/tests/regressiontests/admin_filterspecs/models.py
+++ b/tests/regressiontests/admin_filterspecs/models.py
@@ -2,22 +2,12 @@ from django.db import models
 from django.contrib.auth.models import User
 
 class Book(models.Model):
-    title = models.CharField(max_length=25)
+    title = models.CharField(max_length=50)
     year = models.PositiveIntegerField(null=True, blank=True)
     author = models.ForeignKey(User, related_name='books_authored', blank=True, null=True)
     contributors = models.ManyToManyField(User, related_name='books_contributed', blank=True, null=True)
+    is_best_seller = models.NullBooleanField(default=0)
+    date_registered = models.DateField(null=True)
 
     def __unicode__(self):
         return self.title
-
-class BoolTest(models.Model):
-    NO = False
-    YES = True
-    YES_NO_CHOICES = (
-        (NO, 'no'),
-        (YES, 'yes')
-    )
-    completed = models.BooleanField(
-        default=NO,
-        choices=YES_NO_CHOICES
-    )
diff --git a/tests/regressiontests/admin_filterspecs/tests.py b/tests/regressiontests/admin_filterspecs/tests.py
index 8b9e734..b3c5562 100644
--- a/tests/regressiontests/admin_filterspecs/tests.py
+++ b/tests/regressiontests/admin_filterspecs/tests.py
@@ -1,3 +1,6 @@
+import datetime
+import calendar
+
 from django.contrib.auth.admin import UserAdmin
 from django.test import TestCase
 from django.test.client import RequestFactory
@@ -5,51 +8,161 @@ from django.contrib.auth.models import User
 from django.contrib import admin
 from django.contrib.admin.views.main import ChangeList
 from django.utils.encoding import force_unicode
+from django.contrib.admin.filterspecs import (SimpleListFilter,
+    BooleanFieldListFilter, FieldListFilter)
 
-from models import Book, BoolTest
+from models import Book
 
 def select_by(dictlist, key, value):
     return [x for x in dictlist if x[key] == value][0]
 
-class FilterSpecsTests(TestCase):
 
-    def setUp(self):
-        # Users
-        self.alfred = User.objects.create_user('alfred', 'alfred@example.com')
-        self.bob = User.objects.create_user('bob', 'bob@example.com')
-        lisa = User.objects.create_user('lisa', 'lisa@example.com')
+class DecadeListFilterBase(SimpleListFilter):
+
+    def lookups(self, request):
+        return (
+            (u'the 90s', u'the 1990\'s'),
+            (u'the 00s', u'the 2000\'s'),
+            (u'other', u'other decades'),
+        )
+
+    def queryset(self, request, queryset):
+        decade = self.value()
+        if decade == u'the 90s':
+            return queryset.filter(year__gte=1990, year__lte=1999)
+        if decade == u'the 00s':
+            return queryset.filter(year__gte=2000, year__lte=2009)
+        return queryset
+
+class DecadeListFilterWithTitle(DecadeListFilterBase):
+    title = 'publication decade'
+    parameter_name = 'publication-decade'
 
-        #Books
-        self.bio_book = Book.objects.create(title='Django: a biography', year=1999, author=self.alfred)
-        self.django_book = Book.objects.create(title='The Django Book', year=None, author=self.bob)
-        gipsy_book = Book.objects.create(title='Gipsy guitar for dummies', year=2002)
-        gipsy_book.contributors = [self.bob, lisa]
-        gipsy_book.save()
+class DecadeListFilterWithParamName(DecadeListFilterBase):
+    title = 'another publication decade'
+    parameter_name = 'blah'
 
-        # BoolTests
-        self.trueTest = BoolTest.objects.create(completed=True)
-        self.falseTest = BoolTest.objects.create(completed=False)
+class ListFiltersTests(TestCase):
+
+    def setUp(self):
+        self.today = datetime.date.today()
+        self.one_week_ago = self.today - datetime.timedelta(days=7)
 
         self.request_factory = RequestFactory()
 
+        # Users
+        self.alfred = User.objects.create_user('alfred', 'alfred@example.com')
+        self.bob = User.objects.create_user('bob', 'bob@example.com')
+        self.lisa = User.objects.create_user('lisa', 'lisa@example.com')
+
+        # Books
+        self.djangonaut_book = Book.objects.create(title='Djangonaut: an art of living', year=2009, author=self.alfred, is_best_seller=True, date_registered=self.today)
+        self.bio_book = Book.objects.create(title='Django: a biography', year=1999, author=self.alfred, is_best_seller=False)
+        self.django_book = Book.objects.create(title='The Django Book', year=None, author=self.bob, is_best_seller=None, date_registered=self.today)
+        self.gipsy_book = Book.objects.create(title='Gipsy guitar for dummies', year=2002, is_best_seller=True, date_registered=self.one_week_ago)
+        self.gipsy_book.contributors = [self.bob, self.lisa]
+        self.gipsy_book.save()
 
     def get_changelist(self, request, model, modeladmin):
         return ChangeList(request, model, modeladmin.list_display, modeladmin.list_display_links,
             modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields,
             modeladmin.list_select_related, modeladmin.list_per_page, modeladmin.list_editable, modeladmin)
 
-    def test_AllValuesFilterSpec(self):
+    def test_DateFieldListFilter(self):
+        modeladmin = BookAdmin(Book, admin.site)
+
+        request = self.request_factory.get('/')
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        request = self.request_factory.get('/', {'date_registered__year': self.today.year,
+                                                 'date_registered__month': self.today.month,
+                                                 'date_registered__day': self.today.day})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.django_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][4]
+        self.assertEqual(force_unicode(filterspec.title), u'date_registered')
+        choice = select_by(filterspec.choices(changelist), "display", "Today")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?date_registered__day=%s'
+                                                 '&date_registered__month=%s'
+                                                 '&date_registered__year=%s'
+                                                % (self.today.day, self.today.month, self.today.year))
+
+        request = self.request_factory.get('/', {'date_registered__year': self.today.year,
+                                                 'date_registered__month': self.today.month})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        if (self.today.year, self.today.month) == (self.one_week_ago.year, self.one_week_ago.month):
+            # In case one week ago is in the same month.
+            self.assertEqual(list(queryset), [self.gipsy_book, self.django_book, self.djangonaut_book])
+        else:
+            self.assertEqual(list(queryset), [self.django_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][4]
+        self.assertEqual(force_unicode(filterspec.title), u'date_registered')
+        choice = select_by(filterspec.choices(changelist), "display", "This month")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?date_registered__month=%s'
+                                                 '&date_registered__year=%s'
+                                                % (self.today.month, self.today.year))
+
+        request = self.request_factory.get('/', {'date_registered__year': self.today.year})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        if self.today.year == self.one_week_ago.year:
+            # In case one week ago is in the same year.
+            self.assertEqual(list(queryset), [self.gipsy_book, self.django_book, self.djangonaut_book])
+        else:
+            self.assertEqual(list(queryset), [self.django_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][4]
+        self.assertEqual(force_unicode(filterspec.title), u'date_registered')
+        choice = select_by(filterspec.choices(changelist), "display", "This year")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?date_registered__year=%s'
+                                                % (self.today.year))
+
+        request = self.request_factory.get('/', {'date_registered__gte': self.one_week_ago.strftime('%Y-%m-%d'),
+                                                 'date_registered__lte': self.today.strftime('%Y-%m-%d')})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.gipsy_book, self.django_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][4]
+        self.assertEqual(force_unicode(filterspec.title), u'date_registered')
+        choice = select_by(filterspec.choices(changelist), "display", "Past 7 days")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?date_registered__gte=%s'
+                                                 '&date_registered__lte=%s'
+                                                % (self.one_week_ago.strftime('%Y-%m-%d'), self.today.strftime('%Y-%m-%d')))
+
+    def test_AllValuesFieldListFilter(self):
         modeladmin = BookAdmin(Book, admin.site)
 
         request = self.request_factory.get('/', {'year__isnull': 'True'})
         changelist = self.get_changelist(request, Book, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.django_book])
 
         # Make sure the last choice is None and is selected
         filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'year')
+        self.assertEqual(force_unicode(filterspec.title), u'year')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[-1]['selected'], True)
         self.assertEqual(choices[-1]['query_string'], '?year__isnull=True')
@@ -59,25 +172,24 @@ class FilterSpecsTests(TestCase):
 
         # Make sure the correct choice is selected
         filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'year')
+        self.assertEqual(force_unicode(filterspec.title), u'year')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[2]['selected'], True)
         self.assertEqual(choices[2]['query_string'], '?year=2002')
 
-    def test_RelatedFilterSpec_ForeignKey(self):
+    def test_RelatedFieldListFilter_ForeignKey(self):
         modeladmin = BookAdmin(Book, admin.site)
 
         request = self.request_factory.get('/', {'author__isnull': 'True'})
-        changelist = ChangeList(request, Book, modeladmin.list_display, modeladmin.list_display_links,
-            modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields,
-            modeladmin.list_select_related, modeladmin.list_per_page, modeladmin.list_editable, modeladmin)
+        changelist = self.get_changelist(request, Book, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.gipsy_book])
 
         # Make sure the last choice is None and is selected
         filterspec = changelist.get_filters(request)[0][1]
-        self.assertEqual(force_unicode(filterspec.title()), u'author')
+        self.assertEqual(force_unicode(filterspec.title), u'author')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[-1]['selected'], True)
         self.assertEqual(choices[-1]['query_string'], '?author__isnull=True')
@@ -87,24 +199,25 @@ class FilterSpecsTests(TestCase):
 
         # Make sure the correct choice is selected
         filterspec = changelist.get_filters(request)[0][1]
-        self.assertEqual(force_unicode(filterspec.title()), u'author')
+        self.assertEqual(force_unicode(filterspec.title), u'author')
         # order of choices depends on User model, which has no order
         choice = select_by(filterspec.choices(changelist), "display", "alfred")
         self.assertEqual(choice['selected'], True)
         self.assertEqual(choice['query_string'], '?author__id__exact=%d' % self.alfred.pk)
 
-    def test_RelatedFilterSpec_ManyToMany(self):
+    def test_RelatedFieldListFilter_ManyToMany(self):
         modeladmin = BookAdmin(Book, admin.site)
 
         request = self.request_factory.get('/', {'contributors__isnull': 'True'})
         changelist = self.get_changelist(request, Book, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.django_book, self.bio_book, self.djangonaut_book])
 
         # Make sure the last choice is None and is selected
         filterspec = changelist.get_filters(request)[0][2]
-        self.assertEqual(force_unicode(filterspec.title()), u'user')
+        self.assertEqual(force_unicode(filterspec.title), u'user')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[-1]['selected'], True)
         self.assertEqual(choices[-1]['query_string'], '?contributors__isnull=True')
@@ -114,25 +227,25 @@ class FilterSpecsTests(TestCase):
 
         # Make sure the correct choice is selected
         filterspec = changelist.get_filters(request)[0][2]
-        self.assertEqual(force_unicode(filterspec.title()), u'user')
+        self.assertEqual(force_unicode(filterspec.title), u'user')
         choice = select_by(filterspec.choices(changelist), "display", "bob")
         self.assertEqual(choice['selected'], True)
         self.assertEqual(choice['query_string'], '?contributors__id__exact=%d' % self.bob.pk)
 
-
-    def test_RelatedFilterSpec_reverse_relationships(self):
+    def test_RelatedFieldListFilter_reverse_relationships(self):
         modeladmin = CustomUserAdmin(User, admin.site)
 
         # FK relationship -----
         request = self.request_factory.get('/', {'books_authored__isnull': 'True'})
         changelist = self.get_changelist(request, User, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.lisa])
 
         # Make sure the last choice is None and is selected
         filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'book')
+        self.assertEqual(force_unicode(filterspec.title), u'book')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[-1]['selected'], True)
         self.assertEqual(choices[-1]['query_string'], '?books_authored__isnull=True')
@@ -142,7 +255,7 @@ class FilterSpecsTests(TestCase):
 
         # Make sure the correct choice is selected
         filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'book')
+        self.assertEqual(force_unicode(filterspec.title), u'book')
         choice = select_by(filterspec.choices(changelist), "display", self.bio_book.title)
         self.assertEqual(choice['selected'], True)
         self.assertEqual(choice['query_string'], '?books_authored__id__exact=%d' % self.bio_book.pk)
@@ -151,12 +264,13 @@ class FilterSpecsTests(TestCase):
         request = self.request_factory.get('/', {'books_contributed__isnull': 'True'})
         changelist = self.get_changelist(request, User, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.alfred])
 
         # Make sure the last choice is None and is selected
         filterspec = changelist.get_filters(request)[0][1]
-        self.assertEqual(force_unicode(filterspec.title()), u'book')
+        self.assertEqual(force_unicode(filterspec.title), u'book')
         choices = list(filterspec.choices(changelist))
         self.assertEqual(choices[-1]['selected'], True)
         self.assertEqual(choices[-1]['query_string'], '?books_contributed__isnull=True')
@@ -166,46 +280,162 @@ class FilterSpecsTests(TestCase):
 
         # Make sure the correct choice is selected
         filterspec = changelist.get_filters(request)[0][1]
-        self.assertEqual(force_unicode(filterspec.title()), u'book')
+        self.assertEqual(force_unicode(filterspec.title), u'book')
         choice = select_by(filterspec.choices(changelist), "display", self.django_book.title)
         self.assertEqual(choice['selected'], True)
         self.assertEqual(choice['query_string'], '?books_contributed__id__exact=%d' % self.django_book.pk)
 
-    def test_BooleanFilterSpec(self):
-        modeladmin = BoolTestAdmin(BoolTest, admin.site)
+    def test_BooleanFieldListFilter(self):
+        modeladmin = BookAdmin(Book, admin.site)
+        self.verify_BooleanFieldListFilter(modeladmin)
+
+    def test_BooleanFieldListFilter_Tuple(self):
+        modeladmin = BookAdmin(Book, admin.site)
+        self.verify_BooleanFieldListFilter(modeladmin)
 
+    def verify_BooleanFieldListFilter(self, modeladmin):
         request = self.request_factory.get('/')
-        changelist = ChangeList(request, BoolTest, modeladmin.list_display, modeladmin.list_display_links,
-            modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields,
-            modeladmin.list_select_related, modeladmin.list_per_page, modeladmin.list_editable, modeladmin)
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        request = self.request_factory.get('/', {'is_best_seller__exact': 0})
+        changelist = self.get_changelist(request, Book, modeladmin)
 
-        # Make sure changelist.get_query_set() does not raise IncorrectLookupParameters
+        # Make sure the correct queryset is returned
         queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.bio_book])
 
-        # Make sure the last choice is None and is selected
-        filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'completed')
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(force_unicode(filterspec.title), u'is_best_seller')
+        choice = select_by(filterspec.choices(changelist), "display", "No")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller__exact=0')
+
+        request = self.request_factory.get('/', {'is_best_seller__exact': 1})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.gipsy_book, self.djangonaut_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(force_unicode(filterspec.title), u'is_best_seller')
+        choice = select_by(filterspec.choices(changelist), "display", "Yes")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller__exact=1')
+
+        request = self.request_factory.get('/', {'is_best_seller__isnull': 'True'})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.django_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][3]
+        self.assertEqual(force_unicode(filterspec.title), u'is_best_seller')
+        choice = select_by(filterspec.choices(changelist), "display", "Unknown")
+        self.assertEqual(choice['selected'], True)
+        self.assertEqual(choice['query_string'], '?is_best_seller__isnull=True')
+
+    def test_SimpleListFilter(self):
+        modeladmin = DecadeFilterBookAdmin(Book, admin.site)
+
+        # Make sure that the first option is 'All' ---------------------------
+
+        request = self.request_factory.get('/', {})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), list(Book.objects.all().order_by('-id')))
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][1]
+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
+        choices = list(filterspec.choices(changelist))
+        self.assertEqual(choices[0]['display'], u'All')
+        self.assertEqual(choices[0]['selected'], True)
+        self.assertEqual(choices[0]['query_string'], '?')
+
+        # Make sure that one can override the query parameter name -----------
+
+        request = self.request_factory.get('/', {'blah': 'the 90s'})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][2]
+        self.assertEqual(force_unicode(filterspec.title), u'another publication decade')
+        choices = list(filterspec.choices(changelist))
+        self.assertEqual(choices[1]['display'], u'the 1990\'s')
+        self.assertEqual(choices[1]['selected'], True)
+        self.assertEqual(choices[1]['query_string'], '?blah=the+90s')
+
+        # Look for books in the 1990s ----------------------------------------
+
+        request = self.request_factory.get('/', {'publication-decade': 'the 90s'})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.bio_book])
+
+        # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][1]
+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
         choices = list(filterspec.choices(changelist))
-        self.assertEqual(choices[-1]['selected'], False)
-        self.assertEqual(choices[-1]['query_string'], '?completed__exact=0')
+        self.assertEqual(choices[1]['display'], u'the 1990\'s')
+        self.assertEqual(choices[1]['selected'], True)
+        self.assertEqual(choices[1]['query_string'], '?publication-decade=the+90s')
+
+        # Look for books in the 2000s ----------------------------------------
+
+        request = self.request_factory.get('/', {'publication-decade': 'the 00s'})
+        changelist = self.get_changelist(request, Book, modeladmin)
 
-        request = self.request_factory.get('/', {'completed__exact': 1})
-        changelist = self.get_changelist(request, BoolTest, modeladmin)
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.gipsy_book, self.djangonaut_book])
 
         # Make sure the correct choice is selected
+        filterspec = changelist.get_filters(request)[0][1]
+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
+        choices = list(filterspec.choices(changelist))
+        self.assertEqual(choices[2]['display'], u'the 2000\'s')
+        self.assertEqual(choices[2]['selected'], True)
+        self.assertEqual(choices[2]['query_string'], '?publication-decade=the+00s')
+
+        # Combine multiple filters -------------------------------------------
+
+        request = self.request_factory.get('/', {'publication-decade': 'the 00s', 'author__id__exact': self.alfred.pk})
+        changelist = self.get_changelist(request, Book, modeladmin)
+
+        # Make sure the correct queryset is returned
+        queryset = changelist.get_query_set()
+        self.assertEqual(list(queryset), [self.djangonaut_book])
+
+        # Make sure the correct choices are selected
+        filterspec = changelist.get_filters(request)[0][1]
+        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
+        choices = list(filterspec.choices(changelist))
+        self.assertEqual(choices[2]['display'], u'the 2000\'s')
+        self.assertEqual(choices[2]['selected'], True)
+        self.assertEqual(choices[2]['query_string'], '?publication-decade=the+00s&author__id__exact=%s' % self.alfred.pk)
+
         filterspec = changelist.get_filters(request)[0][0]
-        self.assertEqual(force_unicode(filterspec.title()), u'completed')
-        # order of choices depends on User model, which has no order
-        choice = select_by(filterspec.choices(changelist), "display", "Yes")
+        self.assertEqual(force_unicode(filterspec.title), u'author')
+        choice = select_by(filterspec.choices(changelist), "display", "alfred")
         self.assertEqual(choice['selected'], True)
-        self.assertEqual(choice['query_string'], '?completed__exact=1')
+        self.assertEqual(choice['query_string'], '?publication-decade=the+00s&author__id__exact=%s' % self.alfred.pk)
 
 class CustomUserAdmin(UserAdmin):
     list_filter = ('books_authored', 'books_contributed')
 
 class BookAdmin(admin.ModelAdmin):
-    list_filter = ('year', 'author', 'contributors')
+    list_filter = ('year', 'author', 'contributors', 'is_best_seller', 'date_registered')
     order_by = '-id'
 
-class BoolTestAdmin(admin.ModelAdmin):
-    list_filter = ('completed',)
+class DecadeFilterBookAdmin(admin.ModelAdmin):
+    list_filter = ('author', DecadeListFilterWithTitle, DecadeListFilterWithParamName)
+    order_by = '-id'
diff --git a/tests/regressiontests/modeladmin/tests.py b/tests/regressiontests/modeladmin/tests.py
index a20e579..b6c62d9 100644
--- a/tests/regressiontests/modeladmin/tests.py
+++ b/tests/regressiontests/modeladmin/tests.py
@@ -2,19 +2,21 @@ from datetime import date
 
 from django import forms
 from django.conf import settings
-from django.contrib.admin.options import ModelAdmin, TabularInline, \
-    HORIZONTAL, VERTICAL
+from django.contrib.admin.options import (ModelAdmin, TabularInline,
+    HORIZONTAL, VERTICAL)
 from django.contrib.admin.sites import AdminSite
 from django.contrib.admin.validation import validate
 from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect
+from django.contrib.admin.filterspecs import (SimpleListFilter,
+     BooleanFieldListFilter)
 from django.core.exceptions import ImproperlyConfigured
 from django.forms.models import BaseModelFormSet
 from django.forms.widgets import Select
 from django.test import TestCase
 from django.utils import unittest
 
-from models import Band, Concert, ValidationTestModel, \
-    ValidationTestInlineModel
+from models import (Band, Concert, ValidationTestModel,
+    ValidationTestInlineModel)
 
 
 # None of the following tests really depend on the content of the request,
@@ -851,8 +853,65 @@ class ValidationTests(unittest.TestCase):
             ValidationTestModel,
         )
 
+        class RandomClass(object):
+            pass
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            list_filter = (RandomClass,)
+
+        self.assertRaisesRegexp(
+            ImproperlyConfigured,
+            "'ValidationTestModelAdmin.list_filter\[0\]' is 'RandomClass' which is not a descendant of ListFilterBase.",
+            validate,
+            ValidationTestModelAdmin,
+            ValidationTestModel,
+        )
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            list_filter = (('is_active', RandomClass),)
+
+        self.assertRaisesRegexp(
+            ImproperlyConfigured,
+            "'ValidationTestModelAdmin.list_filter\[0\]\[1\]' is 'RandomClass' which is not of type FieldListFilter.",
+            validate,
+            ValidationTestModelAdmin,
+            ValidationTestModel,
+        )
+
+        class AwesomeFilter(SimpleListFilter):
+            def get_title(self):
+                return 'awesomeness'
+            def get_choices(self, request):
+                return (('bit', 'A bit awesome'), ('very', 'Very awesome'), )
+            def get_query_set(self, cl, qs):
+                return qs
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            list_filter = (('is_active', AwesomeFilter),)
+
+        self.assertRaisesRegexp(
+            ImproperlyConfigured,
+            "'ValidationTestModelAdmin.list_filter\[0\]\[1\]' is 'AwesomeFilter' which is not of type FieldListFilter.",
+            validate,
+            ValidationTestModelAdmin,
+            ValidationTestModel,
+        )
+
+        class ValidationTestModelAdmin(ModelAdmin):
+            list_filter = (BooleanFieldListFilter,)
+
+        self.assertRaisesRegexp(
+            ImproperlyConfigured,
+            "'ValidationTestModelAdmin.list_filter\[0\]' is 'BooleanFieldListFilter' which is of type FieldListFilter but is not associated with a field name.",
+            validate,
+            ValidationTestModelAdmin,
+            ValidationTestModel,
+        )
+
+        # Valid declarations below -----------
+
         class ValidationTestModelAdmin(ModelAdmin):
-            list_filter = ('is_active',)
+            list_filter = ('is_active', AwesomeFilter, ('is_active', BooleanFieldListFilter))
 
         validate(ValidationTestModelAdmin, ValidationTestModel)
 
-- 
1.7.4.1

