Ticket #5833: 5833.custom-filterspecs.4.diff

File 5833.custom-filterspecs.4.diff, 50.0 KB (added by Julien Phalip, 8 years ago)

Cleaner API based on Jacob's feedback so far

  • django/contrib/admin/__init__.py

    diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py
    index f8e634e..44845d7 100644
    a b  
    11# ACTION_CHECKBOX_NAME is unused, but should stay since its import from here
    22# has been referenced in documentation.
     3from django.contrib.admin.filterspecs import ListFilter, FieldListFilter
    34from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
    45from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
    56from django.contrib.admin.options import StackedInline, TabularInline
  • django/contrib/admin/filterspecs.py

    diff --git a/django/contrib/admin/filterspecs.py b/django/contrib/admin/filterspecs.py
    index 965b32b..e8853f7 100644
    a b certain test -- e.g. being a DateField or ForeignKey. 
    77"""
    88
    99from django.db import models
     10from django.core.exceptions import ImproperlyConfigured
    1011from django.utils.encoding import smart_unicode, iri_to_uri
    1112from django.utils.translation import ugettext as _
    1213from django.utils.html import escape
    1314from django.utils.safestring import mark_safe
    14 from django.contrib.admin.util import get_model_from_relation, \
    15     reverse_field_path, get_limit_choices_to_from_path
     15from django.contrib.admin.util import (get_model_from_relation,
     16    reverse_field_path, get_limit_choices_to_from_path)
     17from django.template.defaultfilters import slugify
    1618import datetime
    1719
    18 class FilterSpec(object):
    19     filter_specs = []
    20     def __init__(self, f, request, params, model, model_admin,
    21                  field_path=None):
    22         self.field = f
     20class ListFilterBase(object):
     21    title = None # Human-readable title to appear in the right sidebar.
     22   
     23    def __init__(self, request, params, model, model_admin):
     24        if self.title is None:
     25            raise ImproperlyConfigured("The list filter '%s' does specify "
     26                                       "a 'title'." % self.__class__.__name__)
    2327        self.params = params
    24         self.field_path = field_path
    25         if field_path is None:
    26             if isinstance(f, models.related.RelatedObject):
    27                 self.field_path = f.var_name
    28             else:
    29                 self.field_path = f.name
    30 
    31     def register(cls, test, factory):
    32         cls.filter_specs.append((test, factory))
    33     register = classmethod(register)
    34 
    35     def create(cls, f, request, params, model, model_admin, field_path=None):
    36         for test, factory in cls.filter_specs:
    37             if test(f):
    38                 return factory(f, request, params, model, model_admin,
    39                                field_path=field_path)
    40     create = classmethod(create)
    4128
    4229    def has_output(self):
    4330        return True
    4431
    45     def choices(self, cl):
    46         raise NotImplementedError()
    47 
    48     def title(self):
    49         return self.field.verbose_name
     32    def _choices(self, cl):
     33        raise NotImplementedError
     34
     35    def get_query_set(self, queryset):
     36        return queryset
     37   
     38    def _consumed_params(self):
     39        """
     40        Return a list of parameters to consume from the change list
     41        querystring.
     42       
     43        Override this for non-field based ListFilter subclasses in order
     44        to consume custom GET parameters, as any GET parameters that are not
     45        consumed and are not a field name raises an exception.
     46        """
     47        return []
    5048
    5149    def output(self, cl):
    5250        t = []
    5351        if self.has_output():
    54             t.append(_(u'<h3>By %s:</h3>\n<ul>\n') % escape(self.title()))
     52            t.append(_(u'<h3>By %s:</h3>\n<ul>\n') % escape(self.title))
    5553
    56             for choice in self.choices(cl):
     54            for choice in self._choices(cl):
    5755                t.append(u'<li%s><a href="%s">%s</a></li>\n' % \
    5856                    ((choice['selected'] and ' class="selected"' or ''),
    5957                     iri_to_uri(choice['query_string']),
    6058                     choice['display']))
    6159            t.append('</ul>\n\n')
    6260        return mark_safe("".join(t))
     61   
     62
     63
     64           
     65           
     66class ListFilter(ListFilterBase):
     67    """
     68        API to make the creation of a custom non-field list filter as simple
     69        and easy as possible.
     70    """
     71   
     72    # Parameter that should be used in the query string for that filter.
     73    # Defaults to the title, slugified
     74    query_parameter_name = None
     75   
     76    def __init__(self, request, params, model, model_admin):
     77        super(ListFilter, self).__init__(request, params, model, model_admin)
     78        if self.query_parameter_name is None:
     79            self.query_parameter_name = slugify(self.title)
     80        self.lookup_choices = self.get_choices(request)
     81   
     82    def has_output(self):
     83        return len(self.lookup_choices) > 0
     84   
     85    def get_value(self):
     86        """
     87            Returns the value given in the query string for this filter,
     88            if any. Returns None otherwise.
     89        """
     90        return self.params.get(self.query_parameter_name, None)
     91
     92    def get_choices(self, request):
     93        """
     94            Must be overriden to return a list of tuples (value, verbose value)
     95        """
     96        raise NotImplementedError
     97
     98    def _consumed_params(self):
     99        return [self.query_parameter_name]
     100
     101    def _choices(self, cl):
     102        yield {'selected': self.get_value() is None,
     103               'query_string': cl.get_query_string({}, [self.query_parameter_name]),
     104               'display': _('All')}
     105        for k, v in self.lookup_choices:
     106            yield {'selected': self.get_value() == k,
     107                   'query_string': cl.get_query_string(
     108                                   {self.query_parameter_name: k},
     109                                   []),
     110                   'display': v}
    63111
    64 class RelatedFilterSpec(FilterSpec):
    65     def __init__(self, f, request, params, model, model_admin,
    66                  field_path=None):
    67         super(RelatedFilterSpec, self).__init__(
    68             f, request, params, model, model_admin, field_path=field_path)
    69112
    70         other_model = get_model_from_relation(f)
    71         if isinstance(f, (models.ManyToManyField,
     113           
     114class FieldListFilter(ListFilterBase):
     115    _field_list_filters = []
     116    _take_priority_index = 0
     117
     118    def __init__(self, field, request, params, model, model_admin, \
     119                 field_path):
     120        self.field = field
     121        self.field_path = field_path
     122        self.title = field_path
     123        super(FieldListFilter, self).__init__(request, params, model, \
     124                                              model_admin)
     125
     126
     127    @classmethod
     128    def register(cls, test, list_filter_class, take_priority=False):
     129        if take_priority:
     130            # This is to allow overriding the default filters for certain types
     131            # of fields with some custom filters. The first found in the list
     132            # is used in priority.
     133            cls._field_list_filters.insert(cls._take_priority_index, (test, list_filter_class))
     134            _take_priority_index += 1
     135        else:
     136            cls._field_list_filters.append((test, list_filter_class))
     137   
     138    @classmethod
     139    def create(cls, field, request, params, model, model_admin, field_path):
     140        for test, list_filter_class in cls._field_list_filters:
     141            if test(field):
     142                return list_filter_class(field, request, params, model, model_admin,
     143                               field_path=field_path)
     144
     145       
     146class RelatedFieldListFilter(FieldListFilter):
     147    def __init__(self, field, request, params, model, model_admin,
     148                 field_path):
     149        super(RelatedFieldListFilter, self).__init__(
     150            field, request, params, model, model_admin, field_path)
     151
     152        other_model = get_model_from_relation(field)
     153        if isinstance(field, (models.ManyToManyField,
    72154                          models.related.RelatedObject)):
    73155            # no direct field on this model, get name from other model
    74156            self.lookup_title = other_model._meta.verbose_name
    75157        else:
    76             self.lookup_title = f.verbose_name # use field name
     158            self.lookup_title = field.verbose_name # use field name
    77159        rel_name = other_model._meta.pk.name
    78160        self.lookup_kwarg = '%s__%s__exact' % (self.field_path, rel_name)
    79161        self.lookup_kwarg_isnull = '%s__isnull' % (self.field_path)
    80162        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    81163        self.lookup_val_isnull = request.GET.get(
    82164                                      self.lookup_kwarg_isnull, None)
    83         self.lookup_choices = f.get_choices(include_blank=False)
     165        self.lookup_choices = field.get_choices(include_blank=False)
     166        self.title = self.lookup_title
    84167
    85168    def has_output(self):
    86169        if isinstance(self.field, models.related.RelatedObject) \
    class RelatedFilterSpec(FilterSpec): 
    91174            extra = 0
    92175        return len(self.lookup_choices) + extra > 1
    93176
    94     def title(self):
    95         return self.lookup_title
    96 
    97     def choices(self, cl):
     177    def _choices(self, cl):
    98178        from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
    99179        yield {'selected': self.lookup_val is None
    100180                           and not self.lookup_val_isnull,
    class RelatedFilterSpec(FilterSpec): 
    117197                                   [self.lookup_kwarg]),
    118198                   'display': EMPTY_CHANGELIST_VALUE}
    119199
    120 FilterSpec.register(lambda f: (
     200FieldListFilter.register(lambda f: (
    121201        hasattr(f, 'rel') and bool(f.rel) or
    122         isinstance(f, models.related.RelatedObject)), RelatedFilterSpec)
     202        isinstance(f, models.related.RelatedObject)), RelatedFieldListFilter)
    123203
    124 class BooleanFieldFilterSpec(FilterSpec):
     204class BooleanFieldListFilter(FieldListFilter):
    125205    def __init__(self, f, request, params, model, model_admin,
    126                  field_path=None):
    127         super(BooleanFieldFilterSpec, self).__init__(f, request, params, model,
     206                 field_path):
     207        super(BooleanFieldListFilter, self).__init__(f, request, params, model,
    128208                                                     model_admin,
    129                                                      field_path=field_path)
     209                                                     field_path)
    130210        self.lookup_kwarg = '%s__exact' % self.field_path
    131211        self.lookup_kwarg2 = '%s__isnull' % self.field_path
    132212        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    133213        self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
    134214
    135     def title(self):
    136         return self.field.verbose_name
    137 
    138     def choices(self, cl):
     215    def _choices(self, cl):
    139216        for k, v in ((_('All'), None), (_('Yes'), '1'), (_('No'), '0')):
    140217            yield {'selected': self.lookup_val == v and not self.lookup_val2,
    141218                   'query_string': cl.get_query_string(
    class BooleanFieldFilterSpec(FilterSpec): 
    149226                                   [self.lookup_kwarg]),
    150227                   'display': _('Unknown')}
    151228
    152 FilterSpec.register(lambda f: isinstance(f, models.BooleanField)
     229FieldListFilter.register(lambda f: isinstance(f, models.BooleanField)
    153230                              or isinstance(f, models.NullBooleanField),
    154                                  BooleanFieldFilterSpec)
     231                                 BooleanFieldListFilter)
    155232
    156 class ChoicesFilterSpec(FilterSpec):
     233class ChoicesFieldListFilter(FieldListFilter):
    157234    def __init__(self, f, request, params, model, model_admin,
    158                  field_path=None):
    159         super(ChoicesFilterSpec, self).__init__(f, request, params, model,
     235                 field_path):
     236        super(ChoicesFieldListFilter, self).__init__(f, request, params, model,
    160237                                                model_admin,
    161                                                 field_path=field_path)
     238                                                field_path)
    162239        self.lookup_kwarg = '%s__exact' % self.field_path
    163         self.lookup_val = request.GET.get(self.lookup_kwarg, None)
     240        self.lookup_val = request.GET.get(self.lookup_kwarg)
    164241
    165     def choices(self, cl):
     242    def _choices(self, cl):
    166243        yield {'selected': self.lookup_val is None,
    167244               'query_string': cl.get_query_string({}, [self.lookup_kwarg]),
    168245               'display': _('All')}
    class ChoicesFilterSpec(FilterSpec): 
    172249                                    {self.lookup_kwarg: k}),
    173250                    'display': v}
    174251
    175 FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
     252FieldListFilter.register(lambda f: bool(f.choices), ChoicesFieldListFilter)
    176253
    177 class DateFieldFilterSpec(FilterSpec):
     254class DateFieldListFilter(FieldListFilter):
    178255    def __init__(self, f, request, params, model, model_admin,
    179                  field_path=None):
    180         super(DateFieldFilterSpec, self).__init__(f, request, params, model,
     256                 field_path):
     257        super(DateFieldListFilter, self).__init__(f, request, params, model,
    181258                                                  model_admin,
    182                                                   field_path=field_path)
     259                                                  field_path)
    183260
    184261        self.field_generic = '%s__' % self.field_path
    185262
    class DateFieldFilterSpec(FilterSpec): 
    205282            (_('This year'), {'%s__year' % self.field_path: str(today.year)})
    206283        )
    207284
    208     def title(self):
    209         return self.field.verbose_name
    210 
    211     def choices(self, cl):
     285    def _choices(self, cl):
    212286        for title, param_dict in self.links:
    213287            yield {'selected': self.date_params == param_dict,
    214288                   'query_string': cl.get_query_string(
    class DateFieldFilterSpec(FilterSpec): 
    216290                                   [self.field_generic]),
    217291                   'display': title}
    218292
    219 FilterSpec.register(lambda f: isinstance(f, models.DateField),
    220                               DateFieldFilterSpec)
     293FieldListFilter.register(lambda f: isinstance(f, models.DateField),
     294                              DateFieldListFilter)
    221295
    222296
    223297# This should be registered last, because it's a last resort. For example,
    224 # if a field is eligible to use the BooleanFieldFilterSpec, that'd be much
    225 # more appropriate, and the AllValuesFilterSpec won't get used for it.
    226 class AllValuesFilterSpec(FilterSpec):
     298# if a field is eligible to use the BooleanFieldListFilter, that'd be much
     299# more appropriate, and the AllValuesFieldListFilter won't get used for it.
     300class AllValuesFieldListFilter(FieldListFilter):
    227301    def __init__(self, f, request, params, model, model_admin,
    228                  field_path=None):
    229         super(AllValuesFilterSpec, self).__init__(f, request, params, model,
     302                 field_path):
     303        super(AllValuesFieldListFilter, self).__init__(f, request, params, model,
    230304                                                  model_admin,
    231                                                   field_path=field_path)
     305                                                  field_path)
    232306        self.lookup_kwarg = self.field_path
    233307        self.lookup_kwarg_isnull = '%s__isnull' % self.field_path
    234308        self.lookup_val = request.GET.get(self.lookup_kwarg, None)
    class AllValuesFilterSpec(FilterSpec): 
    245319        self.lookup_choices = \
    246320            queryset.distinct().order_by(f.name).values_list(f.name, flat=True)
    247321
    248     def title(self):
    249         return self.field.verbose_name
    250 
    251     def choices(self, cl):
     322    def _choices(self, cl):
    252323        from django.contrib.admin.views.main import EMPTY_CHANGELIST_VALUE
    253324        yield {'selected': self.lookup_val is None
    254325                           and self.lookup_val_isnull is None,
    class AllValuesFilterSpec(FilterSpec): 
    276347                                    [self.lookup_kwarg]),
    277348                    'display': EMPTY_CHANGELIST_VALUE}
    278349
    279 FilterSpec.register(lambda f: True, AllValuesFilterSpec)
     350FieldListFilter.register(lambda f: True, AllValuesFieldListFilter)
  • django/contrib/admin/templatetags/admin_list.py

    diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
    index fdf082b..4919eda 100644
    a b def search_form(cl): 
    317317search_form = register.inclusion_tag('admin/search_form.html')(search_form)
    318318
    319319def admin_list_filter(cl, spec):
    320     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
     320    return {'title': spec.title, 'choices' : list(spec._choices(cl))}
    321321admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
    322322
    323323def admin_actions(context):
  • django/contrib/admin/validation.py

    diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py
    index 159afa4..cdc86d1 100644
    a b from django.db import models 
    33from django.db.models.fields import FieldDoesNotExist
    44from django.forms.models import (BaseModelForm, BaseModelFormSet, fields_for_model,
    55    _get_foreign_key)
     6from django.contrib.admin.filterspecs import ListFilter, FieldListFilter
    67from django.contrib.admin.util import get_fields_from_path, NotRelationField
    78from django.contrib.admin.options import (flatten_fieldsets, BaseModelAdmin,
    89    HORIZONTAL, VERTICAL)
    def validate(cls, model): 
    5455    # list_filter
    5556    if hasattr(cls, 'list_filter'):
    5657        check_isseq(cls, 'list_filter', cls.list_filter)
    57         for idx, fpath in enumerate(cls.list_filter):
    58             try:
    59                 get_fields_from_path(model, fpath)
    60             except (NotRelationField, FieldDoesNotExist), e:
    61                 raise ImproperlyConfigured(
    62                     "'%s.list_filter[%d]' refers to '%s' which does not refer to a Field." % (
    63                         cls.__name__, idx, fpath
    64                     )
    65                 )
     58        for idx, item in enumerate(cls.list_filter):
     59            # There are three methods of specifying a filter:
     60            #   1: 'field' - a basic field filter, possibly w/ relationships (eg, 'field__rel')
     61            #   2: ('field', SomeFieldListFilter) - a field-based list filter class
     62            #   3: SomeListFilter - a non-field list filter class
     63            if callable(item) and not isinstance(item, models.Field):
     64                # If item is option 3, it should be a ListFilter.
     65                if not issubclass(item, ListFilter):
     66                    raise ImproperlyConfigured("'%s.list_filter[%d]' is '%s'"
     67                            " which is not of type ListFilter."
     68                            % (cls.__name__, idx, item.__name__))
     69            else:
     70                try:
     71                    # Check for option #2 (tuple)
     72                    field, list_filter_class = item
     73                except (TypeError, ValueError):
     74                    # item is option #1
     75                    field = item
     76                else:
     77                    # item is option #2
     78                    if not issubclass(list_filter_class, FieldListFilter):
     79                        raise ImproperlyConfigured("'%s.list_filter[%d][1]'"
     80                            " is '%s' which is not of type FieldListFilter."
     81                            % (cls.__name__, idx, list_filter_class.__name__))
     82                # Validate the field string
     83                try:
     84                    get_fields_from_path(model, field)
     85                except (NotRelationField, FieldDoesNotExist), e:
     86                    raise ImproperlyConfigured("'%s.list_filter[%d]' refers to '%s'"
     87                            " which does not refer to a Field."
     88                            % (cls.__name__, idx, field))
    6689
    6790    # list_per_page = 100
    6891    if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
  • django/contrib/admin/views/main.py

    diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py
    index 170d168..1cfce43 100644
    a b  
    1 from django.contrib.admin.filterspecs import FilterSpec
     1from django.contrib.admin.filterspecs import ListFilter, FieldListFilter
    22from django.contrib.admin.options import IncorrectLookupParameters
    33from django.contrib.admin.util import quote, get_fields_from_path
    44from django.core.exceptions import SuspiciousOperation
    class ChangeList(object): 
    5959            self.list_editable = ()
    6060        else:
    6161            self.list_editable = list_editable
     62        self.filter_specs, self.has_filters = self.get_filters(request)
    6263        self.order_field, self.order_type = self.get_ordering()
    6364        self.query = request.GET.get(SEARCH_VAR, '')
    6465        self.query_set = self.get_query_set()
    6566        self.get_results(request)
    6667        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))
    67         self.filter_specs, self.has_filters = self.get_filters(request)
    6868        self.pk_attname = self.lookup_opts.pk.attname
    6969
    7070    def get_filters(self, request):
    7171        filter_specs = []
    7272        if self.list_filter:
    73             for filter_name in self.list_filter:
    74                 field = get_fields_from_path(self.model, filter_name)[-1]
    75                 spec = FilterSpec.create(field, request, self.params,
    76                                          self.model, self.model_admin,
    77                                          field_path=filter_name)
     73            for item in self.list_filter:
     74                if callable(item):
     75                    # This is simply a custom ListFilter class.
     76                    spec = item(request, self.params, self.model, self.model_admin)
     77                else:
     78                    field_path = None
     79                    try:
     80                        # This is custom FieldListFilter class for a given field.
     81                        field, field_list_filter_class = item
     82                    except (TypeError, ValueError):
     83                        # This is simply a field name, so use the default
     84                        # FieldListFilter class that has been registered for
     85                        # the type of the given field.
     86                        field, field_list_filter_class = item, FieldListFilter.create
     87                    if not isinstance(field, models.Field):
     88                        field_path = field
     89                        field = get_fields_from_path(self.model, field_path)[-1]
     90                    spec = field_list_filter_class(field, request, self.params, self.model,
     91                            self.model_admin, field_path=field_path)
    7892                if spec and spec.has_output():
    7993                    filter_specs.append(spec)
    8094        return filter_specs, bool(filter_specs)
    class ChangeList(object): 
    165179        if ORDER_TYPE_VAR in params and params[ORDER_TYPE_VAR] in ('asc', 'desc'):
    166180            order_type = params[ORDER_TYPE_VAR]
    167181        return order_field, order_type
    168 
     182   
     183    def apply_list_filters(self, qs, lookup_params):
     184        for filter_spec in self.filter_specs:
     185            new_qs = filter_spec.get_query_set(qs)
     186            if new_qs is not None and new_qs is not False:
     187                qs = new_qs
     188                # Only consume params if we got a new queryset
     189                for param in filter_spec._consumed_params():
     190                    try:
     191                        del lookup_params[param]
     192                    except KeyError:
     193                        pass
     194        return qs
     195   
    169196    def get_query_set(self):
    170197        use_distinct = False
    171198
    class ChangeList(object): 
    187214                field_name = key.split('__', 1)[0]
    188215                try:
    189216                    f = self.lookup_opts.get_field_by_name(field_name)[0]
     217                    if hasattr(f, 'rel') and isinstance(f.rel, models.ManyToManyRel):
     218                        use_distinct = True
    190219                except models.FieldDoesNotExist:
    191                     raise IncorrectLookupParameters
    192                 if hasattr(f, 'rel') and isinstance(f.rel, models.ManyToManyRel):
    193                     use_distinct = True
     220                    # It might be for a non-field custom filter specs.
     221                    pass
    194222
    195223            # if key ends with __in, split parameter into separate values
    196224            if key.endswith('__in'):
    class ChangeList(object): 
    209237                raise SuspiciousOperation(
    210238                    "Filtering by %s not allowed" % key
    211239                )
    212 
     240        # Let every list filter modify the qs and params to its liking
     241        qs = self.apply_list_filters(qs, lookup_params)
     242       
    213243        # Apply lookup parameters from the query string.
    214244        try:
    215245            qs = qs.filter(**lookup_params)
  • django/db/models/related.py

    diff --git a/django/db/models/related.py b/django/db/models/related.py
    index 7734230..90995d7 100644
    a b class RelatedObject(object): 
    2727        as SelectField choices for this field.
    2828
    2929        Analogue of django.db.models.fields.Field.get_choices, provided
    30         initially for utilisation by RelatedFilterSpec.
     30        initially for utilisation by RelatedFieldListFilter.
    3131        """
    3232        first_choice = include_blank and blank_choice or []
    3333        queryset = self.model._default_manager.all()
  • docs/ref/contrib/admin/index.txt

    diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt
    index 415e1fe..cd4bfe3 100644
    a b subclass:: 
    512512    .. note::
    513513
    514514        ``list_editable`` interacts with a couple of other options in
    515         particular ways; you should note the following rules:
     515        particular ways; you should note the following rules::
    516516
    517517            * Any field in ``list_editable`` must also be in ``list_display``.
    518518              You can't edit a field that's not displayed!
    subclass:: 
    525525
    526526.. attribute:: ModelAdmin.list_filter
    527527
    528     Set ``list_filter`` to activate filters in the right sidebar of the change
    529     list page of the admin. This should be a list of field names, and each
    530     specified field should be either a ``BooleanField``, ``CharField``,
    531     ``DateField``, ``DateTimeField``, ``IntegerField`` or ``ForeignKey``.
     528    .. versionchanged:: Development version
    532529
    533     This example, taken from the ``django.contrib.auth.models.User`` model,
    534     shows how both ``list_display`` and ``list_filter`` work::
     530    Set ``list_filter`` to activate filters in the right sidebar of the change
     531    list page of the admin. This should be a list of elements, where each
     532    element should be of one of the following types::
     533   
     534        * a field name, where the specified field should be either a
     535          ``BooleanField``, ``CharField``, ``DateField``, ``DateTimeField``,
     536          ``IntegerField``, ``ForeignKey`` or ``ManyToManyField``.
     537
     538            .. versionadded:: 1.3
     539
     540                Field names in ``list_filter`` can also span relations
     541                using the ``__`` lookup, for example::
     542               
     543                    class UserAdminWithLookup(UserAdmin):
     544                        list_filter = ('groups__name')
     545
     546        * a class inheriting from :mod:`django.contrib.admin.ListFilter`,
     547          where you need to provide a few attributes and override a few
     548          methods::
     549       
     550            from django.contrib.admin import ListFilter
     551            from django.db.models import Q
     552           
     553            class DecadeBornListFilter(ListFilter):
     554                # Human-readable title which will be displayed in the
     555                # right sidebar just above the filter options.
     556                title = u'decade born'
     557               
     558                # This is the code name for the filter that will be used in
     559                # the url query. Providing this attribute is optional. If it is
     560                # not provided then a slugified version of the title will
     561                # automatically be used instead (that is, 'decade-born' in this example).
     562                query_parameter_name = u'decade'
     563               
     564                def get_choices(self, request):
     565                    # Return a list of tuples. The first element in each tuple
     566                    # is the coded value for the option that will appear in the
     567                    # url query. The second element is the human-readable name
     568                    # for the option that will appear in the right sidebar. You
     569                    # may specify as many choices as you like, and you may even
     570                    # vary the list of choices depending on the HttpRequest
     571                    # object provided as argument to this method.
     572                    return (
     573                        (u'80s', u'in the eighties'),
     574                        (u'other', u'other'),
     575                    )
     576               
     577                def get_query_set(self, queryset):
     578                    # First, retrieve the requested value (either '80s' or 'other').
     579                    decade = self.get_value()
     580                    # Then decide how to filter the queryset based on that value.
     581                    if decade == u'80s':
     582                        return queryset.filter(birthday__year__gte=1980,
     583                                               birthday__year__lte=1989)
     584                    if decade == u'other':
     585                        return queryset.filter(Q(year__lte=1979) |
     586                                               Q(year__gte=1990)
     587                    # Always return the unchanged queryset by default
     588                    return queryset
     589   
     590            class PersonAdmin(ModelAdmin):
     591                list_filter = (DecadeBornListFilter,)
     592       
     593        * a tuple, where the first element is a field name and the second
     594          element is a class inheriting from
     595          :mod:`django.contrib.admin.FieldListFilter`. Note that the
     596          `FieldListFilter` API is currently considered internal and prone to
     597          refactoring.
     598       
     599    Finally, the following example, taken from the ``django.contrib.auth.models.User``
     600    model, shows how both ``list_display`` and ``list_filter`` work::
    535601
    536602        class UserAdmin(admin.ModelAdmin):
    537603            list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
    subclass:: 
    543609
    544610    (This example also has ``search_fields`` defined. See below.)
    545611
    546     .. versionadded:: 1.3
    547 
    548     Fields in ``list_filter`` can also span relations using the ``__`` lookup::
    549 
    550         class UserAdminWithLookup(UserAdmin):
    551             list_filter = ('groups__name')
    552 
    553612.. attribute:: ModelAdmin.list_per_page
    554613
    555614    Set ``list_per_page`` to control how many items appear on each paginated
  • tests/regressiontests/admin_filterspecs/tests.py

    diff --git a/tests/regressiontests/admin_filterspecs/tests.py b/tests/regressiontests/admin_filterspecs/tests.py
    index 8b9e734..1d5e9a0 100644
    a b from django.contrib.auth.models import User 
    55from django.contrib import admin
    66from django.contrib.admin.views.main import ChangeList
    77from django.utils.encoding import force_unicode
     8from django.contrib.admin.filterspecs import (ListFilter,
     9    BooleanFieldListFilter, FieldListFilter)
    810
    911from models import Book, BoolTest
    1012
    1113def select_by(dictlist, key, value):
    1214    return [x for x in dictlist if x[key] == value][0]
    1315
    14 class FilterSpecsTests(TestCase):
     16
     17
     18class DecadeListFilterBase(ListFilter):
     19   
     20    def get_choices(self, request):
     21        return (
     22            (u'the 90s', u'the 1990\'s'),
     23            (u'the 00s', u'the 2000\'s'),
     24            (u'other', u'other decades'),
     25        )
     26   
     27    def get_query_set(self, qs):
     28        decade = self.get_value()
     29        if decade == u'the 90s':
     30            return qs.filter(year__gte=1990, year__lte=1999)
     31        if decade == u'the 00s':
     32            return qs.filter(year__gte=2000, year__lte=2009)
     33        return qs
     34
     35class DecadeListFilterWithTitle(DecadeListFilterBase):
     36    title = u'publication decade'
     37   
     38class DecadeListFilterWithParamName(DecadeListFilterBase):
     39    title = u'another publication decade'
     40    query_parameter_name = u'blah'
     41
     42class ListFiltersTests(TestCase):
    1543
    1644    def setUp(self):
    1745        # Users
    1846        self.alfred = User.objects.create_user('alfred', 'alfred@example.com')
    1947        self.bob = User.objects.create_user('bob', 'bob@example.com')
    20         lisa = User.objects.create_user('lisa', 'lisa@example.com')
     48        self.lisa = User.objects.create_user('lisa', 'lisa@example.com')
    2149
    2250        #Books
     51        self.djangonaut_book = Book.objects.create(title='Djangonaut: an art of living', year=2009, author=self.alfred)
    2352        self.bio_book = Book.objects.create(title='Django: a biography', year=1999, author=self.alfred)
    2453        self.django_book = Book.objects.create(title='The Django Book', year=None, author=self.bob)
    25         gipsy_book = Book.objects.create(title='Gipsy guitar for dummies', year=2002)
    26         gipsy_book.contributors = [self.bob, lisa]
    27         gipsy_book.save()
     54        self.gipsy_book = Book.objects.create(title='Gipsy guitar for dummies', year=2002)
     55        self.gipsy_book.contributors = [self.bob, self.lisa]
     56        self.gipsy_book.save()
    2857
    2958        # BoolTests
    3059        self.trueTest = BoolTest.objects.create(completed=True)
    class FilterSpecsTests(TestCase): 
    3867            modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields,
    3968            modeladmin.list_select_related, modeladmin.list_per_page, modeladmin.list_editable, modeladmin)
    4069
    41     def test_AllValuesFilterSpec(self):
     70    def test_AllValuesFieldListFilter(self):
    4271        modeladmin = BookAdmin(Book, admin.site)
    4372
    4473        request = self.request_factory.get('/', {'year__isnull': 'True'})
    class FilterSpecsTests(TestCase): 
    4978
    5079        # Make sure the last choice is None and is selected
    5180        filterspec = changelist.get_filters(request)[0][0]
    52         self.assertEqual(force_unicode(filterspec.title()), u'year')
    53         choices = list(filterspec.choices(changelist))
     81        self.assertEqual(force_unicode(filterspec.title), u'year')
     82        choices = list(filterspec._choices(changelist))
    5483        self.assertEqual(choices[-1]['selected'], True)
    5584        self.assertEqual(choices[-1]['query_string'], '?year__isnull=True')
    5685
    class FilterSpecsTests(TestCase): 
    5988
    6089        # Make sure the correct choice is selected
    6190        filterspec = changelist.get_filters(request)[0][0]
    62         self.assertEqual(force_unicode(filterspec.title()), u'year')
    63         choices = list(filterspec.choices(changelist))
     91        self.assertEqual(force_unicode(filterspec.title), u'year')
     92        choices = list(filterspec._choices(changelist))
    6493        self.assertEqual(choices[2]['selected'], True)
    6594        self.assertEqual(choices[2]['query_string'], '?year=2002')
    6695
    67     def test_RelatedFilterSpec_ForeignKey(self):
     96    def test_RelatedFieldListFilter_ForeignKey(self):
    6897        modeladmin = BookAdmin(Book, admin.site)
    6998
    7099        request = self.request_factory.get('/', {'author__isnull': 'True'})
    class FilterSpecsTests(TestCase): 
    77106
    78107        # Make sure the last choice is None and is selected
    79108        filterspec = changelist.get_filters(request)[0][1]
    80         self.assertEqual(force_unicode(filterspec.title()), u'author')
    81         choices = list(filterspec.choices(changelist))
     109        self.assertEqual(force_unicode(filterspec.title), u'author')
     110        choices = list(filterspec._choices(changelist))
    82111        self.assertEqual(choices[-1]['selected'], True)
    83112        self.assertEqual(choices[-1]['query_string'], '?author__isnull=True')
    84113
    class FilterSpecsTests(TestCase): 
    87116
    88117        # Make sure the correct choice is selected
    89118        filterspec = changelist.get_filters(request)[0][1]
    90         self.assertEqual(force_unicode(filterspec.title()), u'author')
     119        self.assertEqual(force_unicode(filterspec.title), u'author')
    91120        # order of choices depends on User model, which has no order
    92         choice = select_by(filterspec.choices(changelist), "display", "alfred")
     121        choice = select_by(filterspec._choices(changelist), "display", "alfred")
    93122        self.assertEqual(choice['selected'], True)
    94123        self.assertEqual(choice['query_string'], '?author__id__exact=%d' % self.alfred.pk)
    95124
    96     def test_RelatedFilterSpec_ManyToMany(self):
     125    def test_RelatedFieldListFilter_ManyToMany(self):
    97126        modeladmin = BookAdmin(Book, admin.site)
    98127
    99128        request = self.request_factory.get('/', {'contributors__isnull': 'True'})
    class FilterSpecsTests(TestCase): 
    104133
    105134        # Make sure the last choice is None and is selected
    106135        filterspec = changelist.get_filters(request)[0][2]
    107         self.assertEqual(force_unicode(filterspec.title()), u'user')
    108         choices = list(filterspec.choices(changelist))
     136        self.assertEqual(force_unicode(filterspec.title), u'user')
     137        choices = list(filterspec._choices(changelist))
    109138        self.assertEqual(choices[-1]['selected'], True)
    110139        self.assertEqual(choices[-1]['query_string'], '?contributors__isnull=True')
    111140
    class FilterSpecsTests(TestCase): 
    114143
    115144        # Make sure the correct choice is selected
    116145        filterspec = changelist.get_filters(request)[0][2]
    117         self.assertEqual(force_unicode(filterspec.title()), u'user')
    118         choice = select_by(filterspec.choices(changelist), "display", "bob")
     146        self.assertEqual(force_unicode(filterspec.title), u'user')
     147        choice = select_by(filterspec._choices(changelist), "display", "bob")
    119148        self.assertEqual(choice['selected'], True)
    120149        self.assertEqual(choice['query_string'], '?contributors__id__exact=%d' % self.bob.pk)
    121150
    122151
    123     def test_RelatedFilterSpec_reverse_relationships(self):
     152    def test_RelatedFieldListFilter_reverse_relationships(self):
    124153        modeladmin = CustomUserAdmin(User, admin.site)
    125154
    126155        # FK relationship -----
    class FilterSpecsTests(TestCase): 
    132161
    133162        # Make sure the last choice is None and is selected
    134163        filterspec = changelist.get_filters(request)[0][0]
    135         self.assertEqual(force_unicode(filterspec.title()), u'book')
    136         choices = list(filterspec.choices(changelist))
     164        self.assertEqual(force_unicode(filterspec.title), u'book')
     165        choices = list(filterspec._choices(changelist))
    137166        self.assertEqual(choices[-1]['selected'], True)
    138167        self.assertEqual(choices[-1]['query_string'], '?books_authored__isnull=True')
    139168
    class FilterSpecsTests(TestCase): 
    142171
    143172        # Make sure the correct choice is selected
    144173        filterspec = changelist.get_filters(request)[0][0]
    145         self.assertEqual(force_unicode(filterspec.title()), u'book')
    146         choice = select_by(filterspec.choices(changelist), "display", self.bio_book.title)
     174        self.assertEqual(force_unicode(filterspec.title), u'book')
     175        choice = select_by(filterspec._choices(changelist), "display", self.bio_book.title)
    147176        self.assertEqual(choice['selected'], True)
    148177        self.assertEqual(choice['query_string'], '?books_authored__id__exact=%d' % self.bio_book.pk)
    149178
    class FilterSpecsTests(TestCase): 
    156185
    157186        # Make sure the last choice is None and is selected
    158187        filterspec = changelist.get_filters(request)[0][1]
    159         self.assertEqual(force_unicode(filterspec.title()), u'book')
    160         choices = list(filterspec.choices(changelist))
     188        self.assertEqual(force_unicode(filterspec.title), u'book')
     189        choices = list(filterspec._choices(changelist))
    161190        self.assertEqual(choices[-1]['selected'], True)
    162191        self.assertEqual(choices[-1]['query_string'], '?books_contributed__isnull=True')
    163192
    class FilterSpecsTests(TestCase): 
    166195
    167196        # Make sure the correct choice is selected
    168197        filterspec = changelist.get_filters(request)[0][1]
    169         self.assertEqual(force_unicode(filterspec.title()), u'book')
    170         choice = select_by(filterspec.choices(changelist), "display", self.django_book.title)
     198        self.assertEqual(force_unicode(filterspec.title), u'book')
     199        choice = select_by(filterspec._choices(changelist), "display", self.django_book.title)
    171200        self.assertEqual(choice['selected'], True)
    172201        self.assertEqual(choice['query_string'], '?books_contributed__id__exact=%d' % self.django_book.pk)
    173202
    174     def test_BooleanFilterSpec(self):
     203    def test_BooleanFieldListFilter(self):
    175204        modeladmin = BoolTestAdmin(BoolTest, admin.site)
    176 
     205        self.verify_BooleanFieldListFilter(modeladmin)
     206       
     207    def test_BooleanFieldListFilter_Tuple(self):
     208        modeladmin = BoolTupleTestAdmin(BoolTest, admin.site)
     209        self.verify_BooleanFieldListFilter(modeladmin)
     210       
     211    def verify_BooleanFieldListFilter(self, modeladmin):
    177212        request = self.request_factory.get('/')
    178213        changelist = ChangeList(request, BoolTest, modeladmin.list_display, modeladmin.list_display_links,
    179214            modeladmin.list_filter, modeladmin.date_hierarchy, modeladmin.search_fields,
    class FilterSpecsTests(TestCase): 
    184219
    185220        # Make sure the last choice is None and is selected
    186221        filterspec = changelist.get_filters(request)[0][0]
    187         self.assertEqual(force_unicode(filterspec.title()), u'completed')
    188         choices = list(filterspec.choices(changelist))
     222        self.assertEqual(force_unicode(filterspec.title), u'completed')
     223        choices = list(filterspec._choices(changelist))
    189224        self.assertEqual(choices[-1]['selected'], False)
    190225        self.assertEqual(choices[-1]['query_string'], '?completed__exact=0')
    191226
    class FilterSpecsTests(TestCase): 
    194229
    195230        # Make sure the correct choice is selected
    196231        filterspec = changelist.get_filters(request)[0][0]
    197         self.assertEqual(force_unicode(filterspec.title()), u'completed')
     232        self.assertEqual(force_unicode(filterspec.title), u'completed')
    198233        # order of choices depends on User model, which has no order
    199         choice = select_by(filterspec.choices(changelist), "display", "Yes")
     234        choice = select_by(filterspec._choices(changelist), "display", "Yes")
    200235        self.assertEqual(choice['selected'], True)
    201236        self.assertEqual(choice['query_string'], '?completed__exact=1')
    202237
     238    def test_ListFilter(self):
     239        modeladmin = DecadeFilterBookAdmin(Book, admin.site)
     240       
     241        # Make sure that the first option is 'All' ---------------------------
     242       
     243        request = self.request_factory.get('/', {})
     244        changelist = self.get_changelist(request, Book, modeladmin)
     245
     246        # Make sure the correct queryset is returned
     247        queryset = changelist.get_query_set()
     248        self.assertEqual(list(queryset), list(Book.objects.all().order_by('-id')))
     249
     250        # Make sure the correct choice is selected
     251        filterspec = changelist.get_filters(request)[0][1]
     252        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
     253        choices = list(filterspec._choices(changelist))
     254        self.assertEqual(choices[0]['display'], u'All')
     255        self.assertEqual(choices[0]['selected'], True)
     256        self.assertEqual(choices[0]['query_string'], '?')
     257       
     258        # Make sure that one can override the query parameter name -----------
     259       
     260        request = self.request_factory.get('/', {'blah': 'the 90s'})
     261        changelist = self.get_changelist(request, Book, modeladmin)
     262       
     263        # Make sure the correct choice is selected
     264        filterspec = changelist.get_filters(request)[0][2]
     265        self.assertEqual(force_unicode(filterspec.title), u'another publication decade')
     266       
     267        # Look for books in the 1990s ----------------------------------------
     268       
     269        request = self.request_factory.get('/', {'publication-decade': 'the 90s'})
     270        changelist = self.get_changelist(request, Book, modeladmin)
     271
     272        # Make sure the correct queryset is returned
     273        queryset = changelist.get_query_set()
     274        self.assertEqual(list(queryset), [self.bio_book])
     275
     276        # Make sure the correct choice is selected
     277        filterspec = changelist.get_filters(request)[0][1]
     278        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
     279        choices = list(filterspec._choices(changelist))
     280        self.assertEqual(choices[1]['display'], u'the 1990\'s')
     281        self.assertEqual(choices[1]['selected'], True)
     282        self.assertEqual(choices[1]['query_string'], '?publication-decade=the+90s')
     283       
     284        # Look for books in the 2000s ----------------------------------------
     285       
     286        request = self.request_factory.get('/', {'publication-decade': 'the 00s'})
     287        changelist = self.get_changelist(request, Book, modeladmin)
     288
     289        # Make sure the correct queryset is returned
     290        queryset = changelist.get_query_set()
     291        self.assertEqual(list(queryset), [self.gipsy_book, self.djangonaut_book])
     292
     293        # Make sure the correct choice is selected
     294        filterspec = changelist.get_filters(request)[0][1]
     295        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
     296        choices = list(filterspec._choices(changelist))
     297        self.assertEqual(choices[2]['display'], u'the 2000\'s')
     298        self.assertEqual(choices[2]['selected'], True)
     299        self.assertEqual(choices[2]['query_string'], '?publication-decade=the+00s')
     300       
     301        # Combine multiple filters -------------------------------------------
     302       
     303        request = self.request_factory.get('/', {'publication-decade': 'the 00s', 'author__id__exact': self.alfred.pk})
     304        changelist = self.get_changelist(request, Book, modeladmin)
     305
     306        # Make sure the correct queryset is returned
     307        queryset = changelist.get_query_set()
     308        self.assertEqual(list(queryset), [self.djangonaut_book])
     309
     310        # Make sure the correct choices are selected
     311        filterspec = changelist.get_filters(request)[0][1]
     312        self.assertEqual(force_unicode(filterspec.title), u'publication decade')
     313        choices = list(filterspec._choices(changelist))
     314        self.assertEqual(choices[2]['display'], u'the 2000\'s')
     315        self.assertEqual(choices[2]['selected'], True)
     316        self.assertEqual(choices[2]['query_string'], '?publication-decade=the+00s&author__id__exact=%s' % self.alfred.pk)
     317       
     318        filterspec = changelist.get_filters(request)[0][0]
     319        self.assertEqual(force_unicode(filterspec.title), u'author')
     320        choice = select_by(filterspec._choices(changelist), "display", "alfred")
     321        self.assertEqual(choice['selected'], True)
     322        self.assertEqual(choice['query_string'], '?publication-decade=the+00s&author__id__exact=%s' % self.alfred.pk)
     323       
    203324class CustomUserAdmin(UserAdmin):
    204325    list_filter = ('books_authored', 'books_contributed')
    205326
    class BookAdmin(admin.ModelAdmin): 
    209330
    210331class BoolTestAdmin(admin.ModelAdmin):
    211332    list_filter = ('completed',)
     333
     334class BoolTupleTestAdmin(admin.ModelAdmin):
     335    list_filter = (('completed', BooleanFieldListFilter),)
     336   
     337class DecadeFilterBookAdmin(admin.ModelAdmin):
     338    list_filter = ('author', DecadeListFilterWithTitle, DecadeListFilterWithParamName)
     339    order_by = '-id'
  • tests/regressiontests/modeladmin/tests.py

    diff --git a/tests/regressiontests/modeladmin/tests.py b/tests/regressiontests/modeladmin/tests.py
    index a20e579..c14f743 100644
    a b from datetime import date 
    22
    33from django import forms
    44from django.conf import settings
    5 from django.contrib.admin.options import ModelAdmin, TabularInline, \
    6     HORIZONTAL, VERTICAL
     5from django.contrib.admin.options import (ModelAdmin, TabularInline,
     6    HORIZONTAL, VERTICAL)
    77from django.contrib.admin.sites import AdminSite
    88from django.contrib.admin.validation import validate
    99from django.contrib.admin.widgets import AdminDateWidget, AdminRadioSelect
     10from django.contrib.admin.filterspecs import (ListFilter,
     11     BooleanFieldListFilter)
    1012from django.core.exceptions import ImproperlyConfigured
    1113from django.forms.models import BaseModelFormSet
    1214from django.forms.widgets import Select
    1315from django.test import TestCase
    1416from django.utils import unittest
    1517
    16 from models import Band, Concert, ValidationTestModel, \
    17     ValidationTestInlineModel
     18from models import (Band, Concert, ValidationTestModel,
     19    ValidationTestInlineModel)
    1820
    1921
    2022# None of the following tests really depend on the content of the request,
    class ValidationTests(unittest.TestCase): 
    850852            ValidationTestModelAdmin,
    851853            ValidationTestModel,
    852854        )
     855       
     856        class RandomClass(object):
     857            pass
     858       
     859        class ValidationTestModelAdmin(ModelAdmin):
     860            list_filter = (RandomClass,)
     861
     862        self.assertRaisesRegexp(
     863            ImproperlyConfigured,
     864            "'ValidationTestModelAdmin.list_filter\[0\]' is 'RandomClass' which is not of type ListFilter.",
     865            validate,
     866            ValidationTestModelAdmin,
     867            ValidationTestModel,
     868        )
     869       
     870        class ValidationTestModelAdmin(ModelAdmin):
     871            list_filter = (('is_active', RandomClass),)
     872
     873        self.assertRaisesRegexp(
     874            ImproperlyConfigured,
     875            "'ValidationTestModelAdmin.list_filter\[0\]\[1\]' is 'RandomClass' which is not of type FieldListFilter.",
     876            validate,
     877            ValidationTestModelAdmin,
     878            ValidationTestModel,
     879        )
     880
     881        class AwesomeFilter(ListFilter):
     882            def get_title(self):
     883                return 'awesomeness'
     884            def get_choices(self, request):
     885                return (('bit', 'A bit awesome'), ('very', 'Very awesome'), )
     886            def get_query_set(self, cl, qs):
     887                return qs
     888       
     889        class ValidationTestModelAdmin(ModelAdmin):
     890            list_filter = (('is_active', AwesomeFilter),)
    853891
     892        self.assertRaisesRegexp(
     893            ImproperlyConfigured,
     894            "'ValidationTestModelAdmin.list_filter\[0\]\[1\]' is 'AwesomeFilter' which is not of type FieldListFilter.",
     895            validate,
     896            ValidationTestModelAdmin,
     897            ValidationTestModel,
     898        )
     899       
     900        # Valid declarations below -----------
     901       
    854902        class ValidationTestModelAdmin(ModelAdmin):
    855             list_filter = ('is_active',)
     903            list_filter = ('is_active', AwesomeFilter, ('is_active', BooleanFieldListFilter))
    856904
    857905        validate(ValidationTestModelAdmin, ValidationTestModel)
    858906
Back to Top