diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py
index f8e634e..86695b1 100644
--- a/django/contrib/admin/__init__.py
+++ b/django/contrib/admin/__init__.py
@@ -1,5 +1,7 @@
# ACTION_CHECKBOX_NAME is unused, but should stay since its import from here
# has been referenced in documentation.
+from django.contrib.admin.filterspecs import ListFilter, FieldListFilter,\
+ SimpleListFilter
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
diff --git a/django/contrib/admin/filterspecs.py b/django/contrib/admin/filterspecs.py
index 965b32b..ac2ca72 100644
--- a/django/contrib/admin/filterspecs.py
+++ b/django/contrib/admin/filterspecs.py
@@ -13,74 +13,158 @@ 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
+from django.template.defaultfilters import slugify
import datetime
-class FilterSpec(object):
- filter_specs = []
- def __init__(self, f, request, params, model, model_admin,
- field_path=None):
- self.field = f
+class ListFilter(object):
+
+ def __init__(self, request, params, model, model_admin):
self.params = params
- 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)
def has_output(self):
return True
- def choices(self, cl):
- raise NotImplementedError()
-
- def title(self):
- return self.field.verbose_name
+ def _choices(self, cl):
+ raise NotImplementedError
+
+ def get_title(self):
+ raise NotImplementedError
+
+ def get_query_set(self, changelist, queryset):
+ return queryset
+
+ def _consumed_params(self):
+ """
+ Return a list of parameters to consume from the change list
+ querystring.
+
+ Override this for non-field based ListFilter subclasses in order
+ to consume custom GET parameters, as any GET parameters that are not
+ consumed and are not a field name raises an exception.
+ """
+ return []
def output(self, cl):
t = []
if self.has_output():
- t.append(_(u'
By %s:
\n\n') % escape(self.title()))
+ t.append(_(u'By %s:
\n\n') % escape(self.get_title()))
- for choice in self.choices(cl):
+ for choice in self._choices(cl):
t.append(u'- %s
\n' % \
((choice['selected'] and ' class="selected"' or ''),
iri_to_uri(choice['query_string']),
choice['display']))
t.append('
\n\n')
return mark_safe("".join(t))
+
-class RelatedFilterSpec(FilterSpec):
- def __init__(self, f, request, params, model, model_admin,
+
+
+
+class SimpleListFilter(ListFilter):
+
+ def __init__(self, request, params, model, model_admin):
+ super(SimpleListFilter, self).__init__(request, params, model, model_admin)
+ self.lookup_kwarg = self.get_query_parameter_name()
+ self.lookup_val = request.GET.get(self.lookup_kwarg, None)
+ self.lookup_choices = self.get_choices(request)
+
+ def has_output(self):
+ return len(self.lookup_choices) > 0
+
+ def get_query_parameter_name(self):
+ """
+ Returns the parameter that should be used in the query string
+ for that filter. Defaults to the title, slugified.
+ """
+ return slugify(self.get_title())
+
+ def get_value(self):
+ """
+ Returns the value given in the query string for this filter,
+ if any. Returns None otherwise.
+ """
+ return self.params.get(self.lookup_kwarg, None)
+
+ def get_choices(self, request):
+ """
+ Must be overriden to return a list of tuples (value, verbose value)
+ """
+ raise NotImplementedError
+
+ def _consumed_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.lookup_choices:
+ yield {'selected': self.lookup_val == k,
+ 'query_string': cl.get_query_string(
+ {self.lookup_kwarg: k},
+ []),
+ 'display': v}
+
+
+
+class FieldListFilter(ListFilter):
+ _field_list_filters = []
+
+ def __init__(self, field, request, params, model, model_admin, \
+ field_path=None):
+ super(FieldListFilter, self).__init__(request, params, model, \
+ model_admin)
+ self.field = field
+ if field_path is None:
+ if isinstance(field, models.related.RelatedObject):
+ self.field_path = field.var_name
+ else:
+ self.field_path = field.name
+ else:
+ self.field_path = field_path
+
+ def get_title(self):
+ return self.field.verbose_name
+
+ @classmethod
+ def register(cls, test, list_filter_class, insert_first=False):
+ if insert_first:
+ # This is to allow overriding default filters for certain types
+ # of fields with custom ones. The first found in the list is used
+ # in priority.
+ cls._field_list_filters.insert(0, (test, list_filter_class))
+ else:
+ cls._field_list_filters.append((test, list_filter_class))
+
+ @classmethod
+ def create(cls, field, request, params, model, model_admin, field_path=None):
+ for test, list_filter_class in cls._field_list_filters:
+ if test(field):
+ 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=None):
- super(RelatedFilterSpec, self).__init__(
- f, request, params, model, model_admin, field_path=field_path)
+ super(RelatedFieldListFilter, self).__init__(
+ field, request, params, model, model_admin, field_path=field_path)
- other_model = get_model_from_relation(f)
- if isinstance(f, (models.ManyToManyField,
+ 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)
def has_output(self):
if isinstance(self.field, models.related.RelatedObject) \
@@ -91,10 +175,10 @@ class RelatedFilterSpec(FilterSpec):
extra = 0
return len(self.lookup_choices) + extra > 1
- def title(self):
+ def get_title(self):
return self.lookup_title
- def choices(self, cl):
+ 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,
@@ -117,14 +201,14 @@ class RelatedFilterSpec(FilterSpec):
[self.lookup_kwarg]),
'display': EMPTY_CHANGELIST_VALUE}
-FilterSpec.register(lambda f: (
+FieldListFilter.register(lambda f: (
hasattr(f, 'rel') and bool(f.rel) or
- isinstance(f, models.related.RelatedObject)), RelatedFilterSpec)
+ isinstance(f, models.related.RelatedObject)), RelatedFieldListFilter)
-class BooleanFieldFilterSpec(FilterSpec):
+class BooleanFieldListFilter(FieldListFilter):
def __init__(self, f, request, params, model, model_admin,
field_path=None):
- super(BooleanFieldFilterSpec, self).__init__(f, request, params, model,
+ super(BooleanFieldListFilter, self).__init__(f, request, params, model,
model_admin,
field_path=field_path)
self.lookup_kwarg = '%s__exact' % self.field_path
@@ -132,10 +216,7 @@ class BooleanFieldFilterSpec(FilterSpec):
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 choices(self, cl):
+ 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(
@@ -149,20 +230,20 @@ class BooleanFieldFilterSpec(FilterSpec):
[self.lookup_kwarg]),
'display': _('Unknown')}
-FilterSpec.register(lambda f: isinstance(f, models.BooleanField)
+FieldListFilter.register(lambda f: isinstance(f, models.BooleanField)
or isinstance(f, models.NullBooleanField),
- BooleanFieldFilterSpec)
+ BooleanFieldListFilter)
-class ChoicesFilterSpec(FilterSpec):
+class ChoicesFieldListFilter(FieldListFilter):
def __init__(self, f, request, params, model, model_admin,
field_path=None):
- super(ChoicesFilterSpec, self).__init__(f, request, params, model,
+ super(ChoicesFieldListFilter, self).__init__(f, request, params, model,
model_admin,
field_path=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 choices(self, cl):
+ def _choices(self, cl):
yield {'selected': self.lookup_val is None,
'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
'display': _('All')}
@@ -172,12 +253,12 @@ class ChoicesFilterSpec(FilterSpec):
{self.lookup_kwarg: k}),
'display': v}
-FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
+FieldListFilter.register(lambda f: bool(f.choices), ChoicesFieldListFilter)
-class DateFieldFilterSpec(FilterSpec):
+class DateFieldListFilter(FieldListFilter):
def __init__(self, f, request, params, model, model_admin,
field_path=None):
- super(DateFieldFilterSpec, self).__init__(f, request, params, model,
+ super(DateFieldListFilter, self).__init__(f, request, params, model,
model_admin,
field_path=field_path)
@@ -205,10 +286,7 @@ class DateFieldFilterSpec(FilterSpec):
(_('This year'), {'%s__year' % self.field_path: str(today.year)})
)
- def title(self):
- return self.field.verbose_name
-
- def choices(self, cl):
+ def _choices(self, cl):
for title, param_dict in self.links:
yield {'selected': self.date_params == param_dict,
'query_string': cl.get_query_string(
@@ -216,17 +294,17 @@ class DateFieldFilterSpec(FilterSpec):
[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):
+# 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, f, request, params, model, model_admin,
field_path=None):
- super(AllValuesFilterSpec, self).__init__(f, request, params, model,
+ super(AllValuesFieldListFilter, self).__init__(f, request, params, model,
model_admin,
field_path=field_path)
self.lookup_kwarg = self.field_path
@@ -245,10 +323,7 @@ class AllValuesFilterSpec(FilterSpec):
self.lookup_choices = \
queryset.distinct().order_by(f.name).values_list(f.name, flat=True)
- def title(self):
- return self.field.verbose_name
-
- def choices(self, cl):
+ 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,
@@ -276,4 +351,4 @@ class AllValuesFilterSpec(FilterSpec):
[self.lookup_kwarg]),
'display': EMPTY_CHANGELIST_VALUE}
-FilterSpec.register(lambda f: True, AllValuesFilterSpec)
+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 fdf082b..6bac03b 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -317,7 +317,7 @@ def search_form(cl):
search_form = register.inclusion_tag('admin/search_form.html')(search_form)
def admin_list_filter(cl, spec):
- return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
+ return {'title': spec.get_title(), 'choices' : list(spec._choices(cl))}
admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
def admin_actions(context):
diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py
index 159afa4..278b45d 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 ListFilter, 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,37 @@ 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 methods of 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 ListFilter, but not a FieldListFilter
+ if not issubclass(item, ListFilter) or issubclass(item, FieldListFilter):
+ raise ImproperlyConfigured("'%s.list_filter[%d]' is '%s'"
+ " which is not of type ListFilter."
+ % (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), e:
+ 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 170d168..de037e6 100644
--- a/django/contrib/admin/views/main.py
+++ b/django/contrib/admin/views/main.py
@@ -1,4 +1,4 @@
-from django.contrib.admin.filterspecs import FilterSpec
+from django.contrib.admin.filterspecs import ListFilter, FieldListFilter
from django.contrib.admin.options import IncorrectLookupParameters
from django.contrib.admin.util import quote, get_fields_from_path
from django.core.exceptions import SuspiciousOperation
@@ -59,22 +59,36 @@ class ChangeList(object):
self.list_editable = ()
else:
self.list_editable = list_editable
+ self.filter_specs, self.has_filters = self.get_filters(request)
self.order_field, self.order_type = self.get_ordering()
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)
self.pk_attname = self.lookup_opts.pk.attname
def get_filters(self, request):
filter_specs = []
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 item in self.list_filter:
+ if callable(item):
+ # This is simply a custom ListFilter class.
+ spec = item(request, self.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 = item
+ 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 = item, 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, self.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)
@@ -166,6 +180,19 @@ class ChangeList(object):
order_type = params[ORDER_TYPE_VAR]
return order_field, order_type
+ def apply_list_filters(self, qs, lookup_params):
+ for filter_spec in self.filter_specs:
+ new_qs = filter_spec.get_query_set(self, qs)
+ if new_qs is not None and new_qs is not False:
+ qs = new_qs
+ # Only consume params if we got a new queryset
+ for param in filter_spec._consumed_params():
+ try:
+ del lookup_params[param]
+ except KeyError:
+ pass
+ return qs
+
def get_query_set(self):
use_distinct = False
@@ -187,10 +214,11 @@ class ChangeList(object):
field_name = key.split('__', 1)[0]
try:
f = self.lookup_opts.get_field_by_name(field_name)[0]
+ if hasattr(f, 'rel') and isinstance(f.rel, models.ManyToManyRel):
+ use_distinct = True
except models.FieldDoesNotExist:
- raise IncorrectLookupParameters
- if hasattr(f, 'rel') and isinstance(f.rel, models.ManyToManyRel):
- use_distinct = True
+ # It might be for a non-field custom filter specs.
+ pass
# if key ends with __in, split parameter into separate values
if key.endswith('__in'):
@@ -209,7 +237,9 @@ class ChangeList(object):
raise SuspiciousOperation(
"Filtering by %s not allowed" % key
)
-
+ # Let every list filter modify the qs and params to its liking
+ qs = self.apply_list_filters(qs, lookup_params)
+
# Apply lookup parameters from the query string.
try:
qs = qs.filter(**lookup_params)
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 415e1fe..a8c6b8b 100644
--- a/docs/ref/contrib/admin/index.txt
+++ b/docs/ref/contrib/admin/index.txt
@@ -512,7 +512,7 @@ subclass::
.. note::
``list_editable`` interacts with a couple of other options in
- particular ways; you should note the following rules:
+ particular ways; you should note the following rules::
* Any field in ``list_editable`` must also be in ``list_display``.
You can't edit a field that's not displayed!
@@ -525,13 +525,90 @@ 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``.
+ .. versionchanged:: Development version
- This example, taken from the ``django.contrib.auth.models.User`` model,
- shows how both ``list_display`` and ``list_filter`` work::
+ Set ``list_filter`` to activate filters in the right sidebar of the change
+ list page of the admin. This 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``.
+
+ .. versionadded:: 1.3
+
+ Field names in ``list_filter`` can also span relations
+ using the ``__`` lookup, for example::
+
+ class UserAdminWithLookup(UserAdmin):
+ list_filter = ('groups__name')
+
+ * a class inheriting from :mod:`django.contrib.admin.SimpleListFilter`,
+ where you need to override a few methods::
+
+ from django.contrib.admin import SimpleListFilter
+ from django.db.models import Q
+
+ class DecadeBornListFilter(SimpleListFilter):
+
+ def get_title(self):
+ # Human-readable title which will be displayed in the
+ # right sidebar just above the filter options.
+ return u'decade born'
+
+ def get_query_parameter_name(self):
+ # This is the code name for the filter that will be used in
+ # the url query. Overriding this method is optional (by default,
+ # a slugified version of the title will automatically be used,
+ # that is, 'decade-born' in this example).
+ return u'decade'
+
+ def get_choices(self, request):
+ # Return 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. You
+ # may specify as many choices as you like, and you may even
+ # vary the list of choices depending on the HttpRequest
+ # object provided as argument to this method.
+ return (
+ (u'80s', u'in the eighties'),
+ (u'other', u'other'),
+ )
+
+ def get_query_set(self, changelist, queryset):
+ # First, retrieve the requested value (either '80s' or 'other').
+ decade = self.get_value()
+ # Then decide how to filter the queryset based on that value.
+ if decade == u'80s':
+ return queryset.filter(birthday__year__gte=1980,
+ birthday__year__lte=1989)
+ if decade == u'other':
+ return queryset.filter(Q(year__lte=1979) |
+ Q(year__gte=1990)
+ # Always return the unchanged queryset by default
+ return queryset
+
+ class PersonAdmin(ModelAdmin):
+ list_filter = (DecadeBornListFilter,)
+
+ * a class inheriting from :mod:`django.contrib.admin.FieldListFilter`,
+ in case you would like to change the default behaviour of a filter
+ for a given field. For example, you may define a custom boolean
+ filter called ``CustomBooleanFieldListFiler``. You may then register
+ it using a tuple to bind it to a given field::
+
+ class PersonAdmin(ModelAdmin):
+ list_filter = (('is_staff', CustomBooleanFieldListFiler),)
+
+ Note that this method is far more complex than simply using a field
+ name or a ``SimpleListFilter`` class, as there currently is no simple
+ way available to manipulate a ``FieldListFilter``. You may, however,
+ find some useful examples with the built-in filters defined in
+ :mod:`django.contrib.admin.filterspecs`.
+
+ Finally, the following example, taken from the ``django.contrib.auth.models.User``
+ model, shows how both ``list_display`` and ``list_filter`` work::
class UserAdmin(admin.ModelAdmin):
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
@@ -543,13 +620,6 @@ subclass::
(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')
-
.. attribute:: ModelAdmin.list_per_page
Set ``list_per_page`` to control how many items appear on each paginated
diff --git a/tests/regressiontests/admin_filterspecs/tests.py b/tests/regressiontests/admin_filterspecs/tests.py
index 8b9e734..7bbc9dd 100644
--- a/tests/regressiontests/admin_filterspecs/tests.py
+++ b/tests/regressiontests/admin_filterspecs/tests.py
@@ -5,26 +5,52 @@ 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
def select_by(dictlist, key, value):
return [x for x in dictlist if x[key] == value][0]
-class FilterSpecsTests(TestCase):
+
+
+class DecadeListFilter(SimpleListFilter):
+
+ def get_title(self):
+ return u'publication decade'
+
+ def get_choices(self, request):
+ return (
+ (u'the 90s', u'the 1990\'s'),
+ (u'the 00s', u'the 2000\'s'),
+ (u'other', u'other decades'),
+ )
+
+ def get_query_set(self, cl, qs):
+ decade = self.get_value()
+ if decade == u'the 90s':
+ return qs.filter(year__gte=1990, year__lte=1999)
+ if decade == u'the 00s':
+ return qs.filter(year__gte=2000, year__lte=2009)
+ return qs
+
+
+class ListFiltersTests(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')
+ 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)
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()
+ self.gipsy_book = Book.objects.create(title='Gipsy guitar for dummies', year=2002)
+ self.gipsy_book.contributors = [self.bob, self.lisa]
+ self.gipsy_book.save()
# BoolTests
self.trueTest = BoolTest.objects.create(completed=True)
@@ -38,7 +64,7 @@ class FilterSpecsTests(TestCase):
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_AllValuesFieldListFilter(self):
modeladmin = BookAdmin(Book, admin.site)
request = self.request_factory.get('/', {'year__isnull': 'True'})
@@ -49,8 +75,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_title()), u'year')
+ choices = list(filterspec._choices(changelist))
self.assertEqual(choices[-1]['selected'], True)
self.assertEqual(choices[-1]['query_string'], '?year__isnull=True')
@@ -59,12 +85,12 @@ 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_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'})
@@ -77,8 +103,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_title()), u'author')
+ choices = list(filterspec._choices(changelist))
self.assertEqual(choices[-1]['selected'], True)
self.assertEqual(choices[-1]['query_string'], '?author__isnull=True')
@@ -87,13 +113,13 @@ 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.get_title()), u'author')
# order of choices depends on User model, which has no order
- choice = select_by(filterspec.choices(changelist), "display", "alfred")
+ 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'})
@@ -104,8 +130,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_title()), u'user')
+ choices = list(filterspec._choices(changelist))
self.assertEqual(choices[-1]['selected'], True)
self.assertEqual(choices[-1]['query_string'], '?contributors__isnull=True')
@@ -114,13 +140,13 @@ 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')
- choice = select_by(filterspec.choices(changelist), "display", "bob")
+ self.assertEqual(force_unicode(filterspec.get_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 -----
@@ -132,8 +158,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_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,8 +168,8 @@ 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')
- choice = select_by(filterspec.choices(changelist), "display", self.bio_book.title)
+ self.assertEqual(force_unicode(filterspec.get_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)
@@ -156,8 +182,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_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,14 +192,20 @@ 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')
- choice = select_by(filterspec.choices(changelist), "display", self.django_book.title)
+ self.assertEqual(force_unicode(filterspec.get_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):
+ def test_BooleanFieldListFilter(self):
modeladmin = BoolTestAdmin(BoolTest, admin.site)
-
+ self.verify_BooleanFieldListFilter(modeladmin)
+
+ def test_BooleanFieldListFilter_Tuple(self):
+ modeladmin = BoolTupleTestAdmin(BoolTest, 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,
@@ -184,8 +216,8 @@ class FilterSpecsTests(TestCase):
# 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')
- choices = list(filterspec.choices(changelist))
+ self.assertEqual(force_unicode(filterspec.get_title()), u'completed')
+ choices = list(filterspec._choices(changelist))
self.assertEqual(choices[-1]['selected'], False)
self.assertEqual(choices[-1]['query_string'], '?completed__exact=0')
@@ -194,12 +226,89 @@ class FilterSpecsTests(TestCase):
# Make sure the correct choice is selected
filterspec = changelist.get_filters(request)[0][0]
- self.assertEqual(force_unicode(filterspec.title()), u'completed')
+ self.assertEqual(force_unicode(filterspec.get_title()), u'completed')
# order of choices depends on User model, which has no order
- choice = select_by(filterspec.choices(changelist), "display", "Yes")
+ choice = select_by(filterspec._choices(changelist), "display", "Yes")
self.assertEqual(choice['selected'], True)
self.assertEqual(choice['query_string'], '?completed__exact=1')
+ 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.get_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'], '?')
+
+ # 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.get_title()), u'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'], '?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)
+
+ # 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.get_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.get_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.get_title()), u'author')
+ choice = select_by(filterspec._choices(changelist), "display", "alfred")
+ self.assertEqual(choice['selected'], True)
+ 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')
@@ -209,3 +318,10 @@ class BookAdmin(admin.ModelAdmin):
class BoolTestAdmin(admin.ModelAdmin):
list_filter = ('completed',)
+
+class BoolTupleTestAdmin(admin.ModelAdmin):
+ list_filter = (('completed', BooleanFieldListFilter),)
+
+class DecadeFilterBookAdmin(admin.ModelAdmin):
+ list_filter = ('author', DecadeListFilter)
+ order_by = '-id'
diff --git a/tests/regressiontests/modeladmin/tests.py b/tests/regressiontests/modeladmin/tests.py
index a20e579..6f31e7f 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,
@@ -850,9 +852,55 @@ class ValidationTests(unittest.TestCase):
ValidationTestModelAdmin,
ValidationTestModel,
)
+
+ class RandomClass(object):
+ pass
+
+ class ValidationTestModelAdmin(ModelAdmin):
+ list_filter = (RandomClass,)
+
+ self.assertRaisesRegexp(
+ ImproperlyConfigured,
+ "'ValidationTestModelAdmin.list_filter\[0\]' is 'RandomClass' which is not of type ListFilter.",
+ 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,
+ )
+
+ # Valid declarations below -----------
+
class ValidationTestModelAdmin(ModelAdmin):
- list_filter = ('is_active',)
+ list_filter = ('is_active', AwesomeFilter, ('is_active', BooleanFieldListFilter))
validate(ValidationTestModelAdmin, ValidationTestModel)