Fixed #3400 -- Support for lookup separator with list_filter admin option. Thanks to DrMeers and vitek_pliska for the patch!
git-svn-id: http://code.djangoproject.com/svn/django/trunk@14674 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
parent
274aba3b9b
commit
dc334a2ba8
|
@ -11,22 +11,32 @@ from django.utils.encoding import smart_unicode, iri_to_uri
|
||||||
from django.utils.translation import ugettext as _
|
from django.utils.translation import ugettext as _
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.contrib.admin.util import get_model_from_relation, \
|
||||||
|
reverse_field_path, get_limit_choices_to_from_path
|
||||||
import datetime
|
import datetime
|
||||||
|
|
||||||
class FilterSpec(object):
|
class FilterSpec(object):
|
||||||
filter_specs = []
|
filter_specs = []
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
|
field_path=None):
|
||||||
self.field = f
|
self.field = f
|
||||||
self.params = params
|
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):
|
def register(cls, test, factory):
|
||||||
cls.filter_specs.append((test, factory))
|
cls.filter_specs.append((test, factory))
|
||||||
register = classmethod(register)
|
register = classmethod(register)
|
||||||
|
|
||||||
def create(cls, f, request, params, model, model_admin):
|
def create(cls, f, request, params, model, model_admin, field_path=None):
|
||||||
for test, factory in cls.filter_specs:
|
for test, factory in cls.filter_specs:
|
||||||
if test(f):
|
if test(f):
|
||||||
return factory(f, request, params, model, model_admin)
|
return factory(f, request, params, model, model_admin,
|
||||||
|
field_path=field_path)
|
||||||
create = classmethod(create)
|
create = classmethod(create)
|
||||||
|
|
||||||
def has_output(self):
|
def has_output(self):
|
||||||
|
@ -52,14 +62,20 @@ class FilterSpec(object):
|
||||||
return mark_safe("".join(t))
|
return mark_safe("".join(t))
|
||||||
|
|
||||||
class RelatedFilterSpec(FilterSpec):
|
class RelatedFilterSpec(FilterSpec):
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
super(RelatedFilterSpec, self).__init__(f, request, params, model, model_admin)
|
field_path=None):
|
||||||
if isinstance(f, models.ManyToManyField):
|
super(RelatedFilterSpec, self).__init__(
|
||||||
self.lookup_title = f.rel.to._meta.verbose_name
|
f, request, params, model, model_admin, field_path=field_path)
|
||||||
|
|
||||||
|
other_model = get_model_from_relation(f)
|
||||||
|
if isinstance(f, (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:
|
else:
|
||||||
self.lookup_title = f.verbose_name
|
self.lookup_title = f.verbose_name # use field name
|
||||||
rel_name = f.rel.get_related_field().name
|
rel_name = other_model._meta.pk.name
|
||||||
self.lookup_kwarg = '%s__%s__exact' % (f.name, rel_name)
|
self.lookup_kwarg = '%s__%s__exact' % (self.field_path, rel_name)
|
||||||
self.lookup_val = request.GET.get(self.lookup_kwarg, None)
|
self.lookup_val = request.GET.get(self.lookup_kwarg, None)
|
||||||
self.lookup_choices = f.get_choices(include_blank=False)
|
self.lookup_choices = f.get_choices(include_blank=False)
|
||||||
|
|
||||||
|
@ -78,12 +94,17 @@ class RelatedFilterSpec(FilterSpec):
|
||||||
'query_string': cl.get_query_string({self.lookup_kwarg: pk_val}),
|
'query_string': cl.get_query_string({self.lookup_kwarg: pk_val}),
|
||||||
'display': val}
|
'display': val}
|
||||||
|
|
||||||
FilterSpec.register(lambda f: bool(f.rel), RelatedFilterSpec)
|
FilterSpec.register(lambda f: (
|
||||||
|
hasattr(f, 'rel') and bool(f.rel) or
|
||||||
|
isinstance(f, models.related.RelatedObject)), RelatedFilterSpec)
|
||||||
|
|
||||||
class ChoicesFilterSpec(FilterSpec):
|
class ChoicesFilterSpec(FilterSpec):
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
super(ChoicesFilterSpec, self).__init__(f, request, params, model, model_admin)
|
field_path=None):
|
||||||
self.lookup_kwarg = '%s__exact' % f.name
|
super(ChoicesFilterSpec, 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, None)
|
||||||
|
|
||||||
def choices(self, cl):
|
def choices(self, cl):
|
||||||
|
@ -98,10 +119,13 @@ class ChoicesFilterSpec(FilterSpec):
|
||||||
FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
|
FilterSpec.register(lambda f: bool(f.choices), ChoicesFilterSpec)
|
||||||
|
|
||||||
class DateFieldFilterSpec(FilterSpec):
|
class DateFieldFilterSpec(FilterSpec):
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
super(DateFieldFilterSpec, self).__init__(f, request, params, model, model_admin)
|
field_path=None):
|
||||||
|
super(DateFieldFilterSpec, self).__init__(f, request, params, model,
|
||||||
|
model_admin,
|
||||||
|
field_path=field_path)
|
||||||
|
|
||||||
self.field_generic = '%s__' % self.field.name
|
self.field_generic = '%s__' % self.field_path
|
||||||
|
|
||||||
self.date_params = dict([(k, v) for k, v in params.items() if k.startswith(self.field_generic)])
|
self.date_params = dict([(k, v) for k, v in params.items() if k.startswith(self.field_generic)])
|
||||||
|
|
||||||
|
@ -111,14 +135,15 @@ class DateFieldFilterSpec(FilterSpec):
|
||||||
|
|
||||||
self.links = (
|
self.links = (
|
||||||
(_('Any date'), {}),
|
(_('Any date'), {}),
|
||||||
(_('Today'), {'%s__year' % self.field.name: str(today.year),
|
(_('Today'), {'%s__year' % self.field_path: str(today.year),
|
||||||
'%s__month' % self.field.name: str(today.month),
|
'%s__month' % self.field_path: str(today.month),
|
||||||
'%s__day' % self.field.name: str(today.day)}),
|
'%s__day' % self.field_path: str(today.day)}),
|
||||||
(_('Past 7 days'), {'%s__gte' % self.field.name: one_week_ago.strftime('%Y-%m-%d'),
|
(_('Past 7 days'), {'%s__gte' % self.field_path:
|
||||||
'%s__lte' % f.name: today_str}),
|
one_week_ago.strftime('%Y-%m-%d'),
|
||||||
(_('This month'), {'%s__year' % self.field.name: str(today.year),
|
'%s__lte' % self.field_path: today_str}),
|
||||||
'%s__month' % f.name: str(today.month)}),
|
(_('This month'), {'%s__year' % self.field_path: str(today.year),
|
||||||
(_('This year'), {'%s__year' % self.field.name: str(today.year)})
|
'%s__month' % self.field_path: str(today.month)}),
|
||||||
|
(_('This year'), {'%s__year' % self.field_path: str(today.year)})
|
||||||
)
|
)
|
||||||
|
|
||||||
def title(self):
|
def title(self):
|
||||||
|
@ -133,10 +158,13 @@ class DateFieldFilterSpec(FilterSpec):
|
||||||
FilterSpec.register(lambda f: isinstance(f, models.DateField), DateFieldFilterSpec)
|
FilterSpec.register(lambda f: isinstance(f, models.DateField), DateFieldFilterSpec)
|
||||||
|
|
||||||
class BooleanFieldFilterSpec(FilterSpec):
|
class BooleanFieldFilterSpec(FilterSpec):
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
super(BooleanFieldFilterSpec, self).__init__(f, request, params, model, model_admin)
|
field_path=None):
|
||||||
self.lookup_kwarg = '%s__exact' % f.name
|
super(BooleanFieldFilterSpec, self).__init__(f, request, params, model,
|
||||||
self.lookup_kwarg2 = '%s__isnull' % f.name
|
model_admin,
|
||||||
|
field_path=field_path)
|
||||||
|
self.lookup_kwarg = '%s__exact' % self.field_path
|
||||||
|
self.lookup_kwarg2 = '%s__isnull' % self.field_path
|
||||||
self.lookup_val = request.GET.get(self.lookup_kwarg, None)
|
self.lookup_val = request.GET.get(self.lookup_kwarg, None)
|
||||||
self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
|
self.lookup_val2 = request.GET.get(self.lookup_kwarg2, None)
|
||||||
|
|
||||||
|
@ -159,21 +187,33 @@ FilterSpec.register(lambda f: isinstance(f, models.BooleanField) or isinstance(f
|
||||||
# if a field is eligible to use the BooleanFieldFilterSpec, that'd be much
|
# if a field is eligible to use the BooleanFieldFilterSpec, that'd be much
|
||||||
# more appropriate, and the AllValuesFilterSpec won't get used for it.
|
# more appropriate, and the AllValuesFilterSpec won't get used for it.
|
||||||
class AllValuesFilterSpec(FilterSpec):
|
class AllValuesFilterSpec(FilterSpec):
|
||||||
def __init__(self, f, request, params, model, model_admin):
|
def __init__(self, f, request, params, model, model_admin,
|
||||||
super(AllValuesFilterSpec, self).__init__(f, request, params, model, model_admin)
|
field_path=None):
|
||||||
self.lookup_val = request.GET.get(f.name, None)
|
super(AllValuesFilterSpec, self).__init__(f, request, params, model,
|
||||||
self.lookup_choices = model_admin.queryset(request).distinct().order_by(f.name).values(f.name)
|
model_admin,
|
||||||
|
field_path=field_path)
|
||||||
|
self.lookup_val = request.GET.get(self.field_path, None)
|
||||||
|
parent_model, reverse_path = reverse_field_path(model, field_path)
|
||||||
|
queryset = parent_model._default_manager.all()
|
||||||
|
# optional feature: limit choices base on existing relationships
|
||||||
|
# queryset = queryset.complex_filter(
|
||||||
|
# {'%s__isnull' % reverse_path: False})
|
||||||
|
limit_choices_to = get_limit_choices_to_from_path(model, field_path)
|
||||||
|
queryset = queryset.filter(limit_choices_to)
|
||||||
|
|
||||||
|
self.lookup_choices = \
|
||||||
|
queryset.distinct().order_by(f.name).values(f.name)
|
||||||
|
|
||||||
def title(self):
|
def title(self):
|
||||||
return self.field.verbose_name
|
return self.field.verbose_name
|
||||||
|
|
||||||
def choices(self, cl):
|
def choices(self, cl):
|
||||||
yield {'selected': self.lookup_val is None,
|
yield {'selected': self.lookup_val is None,
|
||||||
'query_string': cl.get_query_string({}, [self.field.name]),
|
'query_string': cl.get_query_string({}, [self.field_path]),
|
||||||
'display': _('All')}
|
'display': _('All')}
|
||||||
for val in self.lookup_choices:
|
for val in self.lookup_choices:
|
||||||
val = smart_unicode(val[self.field.name])
|
val = smart_unicode(val[self.field.name])
|
||||||
yield {'selected': self.lookup_val == val,
|
yield {'selected': self.lookup_val == val,
|
||||||
'query_string': cl.get_query_string({self.field.name: val}),
|
'query_string': cl.get_query_string({self.field_path: val}),
|
||||||
'display': val}
|
'display': val}
|
||||||
FilterSpec.register(lambda f: True, AllValuesFilterSpec)
|
FilterSpec.register(lambda f: True, AllValuesFilterSpec)
|
||||||
|
|
|
@ -1,4 +1,5 @@
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.sql.constants import LOOKUP_SEP
|
||||||
from django.db.models.deletion import Collector
|
from django.db.models.deletion import Collector
|
||||||
from django.db.models.related import RelatedObject
|
from django.db.models.related import RelatedObject
|
||||||
from django.forms.forms import pretty_name
|
from django.forms.forms import pretty_name
|
||||||
|
@ -280,3 +281,95 @@ def display_for_field(value, field):
|
||||||
return formats.number_format(value)
|
return formats.number_format(value)
|
||||||
else:
|
else:
|
||||||
return smart_unicode(value)
|
return smart_unicode(value)
|
||||||
|
|
||||||
|
|
||||||
|
class NotRelationField(Exception):
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def get_model_from_relation(field):
|
||||||
|
if isinstance(field, models.related.RelatedObject):
|
||||||
|
return field.model
|
||||||
|
elif getattr(field, 'rel'): # or isinstance?
|
||||||
|
return field.rel.to
|
||||||
|
else:
|
||||||
|
raise NotRelationField
|
||||||
|
|
||||||
|
|
||||||
|
def reverse_field_path(model, path):
|
||||||
|
""" Create a reversed field path.
|
||||||
|
|
||||||
|
E.g. Given (Order, "user__groups"),
|
||||||
|
return (Group, "user__order").
|
||||||
|
|
||||||
|
Final field must be a related model, not a data field.
|
||||||
|
|
||||||
|
"""
|
||||||
|
reversed_path = []
|
||||||
|
parent = model
|
||||||
|
pieces = path.split(LOOKUP_SEP)
|
||||||
|
for piece in pieces:
|
||||||
|
field, model, direct, m2m = parent._meta.get_field_by_name(piece)
|
||||||
|
# skip trailing data field if extant:
|
||||||
|
if len(reversed_path) == len(pieces)-1: # final iteration
|
||||||
|
try:
|
||||||
|
get_model_from_relation(field)
|
||||||
|
except NotRelationField:
|
||||||
|
break
|
||||||
|
if direct:
|
||||||
|
related_name = field.related_query_name()
|
||||||
|
parent = field.rel.to
|
||||||
|
else:
|
||||||
|
related_name = field.field.name
|
||||||
|
parent = field.model
|
||||||
|
reversed_path.insert(0, related_name)
|
||||||
|
return (parent, LOOKUP_SEP.join(reversed_path))
|
||||||
|
|
||||||
|
|
||||||
|
def get_fields_from_path(model, path):
|
||||||
|
""" Return list of Fields given path relative to model.
|
||||||
|
|
||||||
|
e.g. (ModelX, "user__groups__name") -> [
|
||||||
|
<django.db.models.fields.related.ForeignKey object at 0x...>,
|
||||||
|
<django.db.models.fields.related.ManyToManyField object at 0x...>,
|
||||||
|
<django.db.models.fields.CharField object at 0x...>,
|
||||||
|
]
|
||||||
|
"""
|
||||||
|
pieces = path.split(LOOKUP_SEP)
|
||||||
|
fields = []
|
||||||
|
for piece in pieces:
|
||||||
|
if fields:
|
||||||
|
parent = get_model_from_relation(fields[-1])
|
||||||
|
else:
|
||||||
|
parent = model
|
||||||
|
fields.append(parent._meta.get_field_by_name(piece)[0])
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def remove_trailing_data_field(fields):
|
||||||
|
""" Discard trailing non-relation field if extant. """
|
||||||
|
try:
|
||||||
|
get_model_from_relation(fields[-1])
|
||||||
|
except NotRelationField:
|
||||||
|
fields = fields[:-1]
|
||||||
|
return fields
|
||||||
|
|
||||||
|
|
||||||
|
def get_limit_choices_to_from_path(model, path):
|
||||||
|
""" Return Q object for limiting choices if applicable.
|
||||||
|
|
||||||
|
If final model in path is linked via a ForeignKey or ManyToManyField which
|
||||||
|
has a `limit_choices_to` attribute, return it as a Q object.
|
||||||
|
"""
|
||||||
|
|
||||||
|
fields = get_fields_from_path(model, path)
|
||||||
|
fields = remove_trailing_data_field(fields)
|
||||||
|
limit_choices_to = (
|
||||||
|
fields and hasattr(fields[-1], 'rel') and
|
||||||
|
getattr(fields[-1].rel, 'limit_choices_to', None))
|
||||||
|
if not limit_choices_to:
|
||||||
|
return models.Q() # empty Q
|
||||||
|
elif isinstance(limit_choices_to, models.Q):
|
||||||
|
return limit_choices_to # already a Q
|
||||||
|
else:
|
||||||
|
return models.Q(**limit_choices_to) # convert dict to Q
|
||||||
|
|
|
@ -1,7 +1,9 @@
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
from django.db.models.fields import FieldDoesNotExist
|
||||||
from django.forms.models import (BaseModelForm, BaseModelFormSet, fields_for_model,
|
from django.forms.models import (BaseModelForm, BaseModelFormSet, fields_for_model,
|
||||||
_get_foreign_key)
|
_get_foreign_key)
|
||||||
|
from django.contrib.admin.util import get_fields_from_path, NotRelationField
|
||||||
from django.contrib.admin.options import flatten_fieldsets, BaseModelAdmin
|
from django.contrib.admin.options import flatten_fieldsets, BaseModelAdmin
|
||||||
from django.contrib.admin.options import HORIZONTAL, VERTICAL
|
from django.contrib.admin.options import HORIZONTAL, VERTICAL
|
||||||
|
|
||||||
|
@ -53,8 +55,15 @@ def validate(cls, model):
|
||||||
# list_filter
|
# list_filter
|
||||||
if hasattr(cls, 'list_filter'):
|
if hasattr(cls, 'list_filter'):
|
||||||
check_isseq(cls, 'list_filter', cls.list_filter)
|
check_isseq(cls, 'list_filter', cls.list_filter)
|
||||||
for idx, field in enumerate(cls.list_filter):
|
for idx, fpath in enumerate(cls.list_filter):
|
||||||
get_field(cls, model, opts, 'list_filter[%d]' % idx, field)
|
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
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
# list_per_page = 100
|
# list_per_page = 100
|
||||||
if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
|
if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
from django.contrib.admin.filterspecs import FilterSpec
|
from django.contrib.admin.filterspecs import FilterSpec
|
||||||
from django.contrib.admin.options import IncorrectLookupParameters
|
from django.contrib.admin.options import IncorrectLookupParameters
|
||||||
from django.contrib.admin.util import quote
|
from django.contrib.admin.util import quote, get_fields_from_path
|
||||||
from django.core.paginator import Paginator, InvalidPage
|
from django.core.paginator import Paginator, InvalidPage
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.utils.encoding import force_unicode, smart_str
|
from django.utils.encoding import force_unicode, smart_str
|
||||||
|
@ -68,9 +68,11 @@ class ChangeList(object):
|
||||||
def get_filters(self, request):
|
def get_filters(self, request):
|
||||||
filter_specs = []
|
filter_specs = []
|
||||||
if self.list_filter:
|
if self.list_filter:
|
||||||
filter_fields = [self.lookup_opts.get_field(field_name) for field_name in self.list_filter]
|
for filter_name in self.list_filter:
|
||||||
for f in filter_fields:
|
field = get_fields_from_path(self.model, filter_name)[-1]
|
||||||
spec = FilterSpec.create(f, request, self.params, self.model, self.model_admin)
|
spec = FilterSpec.create(field, request, self.params,
|
||||||
|
self.model, self.model_admin,
|
||||||
|
field_path=filter_name)
|
||||||
if spec and spec.has_output():
|
if spec and spec.has_output():
|
||||||
filter_specs.append(spec)
|
filter_specs.append(spec)
|
||||||
return filter_specs, bool(filter_specs)
|
return filter_specs, bool(filter_specs)
|
||||||
|
|
|
@ -1,3 +1,6 @@
|
||||||
|
from django.utils.encoding import smart_unicode
|
||||||
|
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||||
|
|
||||||
class BoundRelatedObject(object):
|
class BoundRelatedObject(object):
|
||||||
def __init__(self, related_object, field_mapping, original):
|
def __init__(self, related_object, field_mapping, original):
|
||||||
self.relation = related_object
|
self.relation = related_object
|
||||||
|
@ -18,6 +21,22 @@ class RelatedObject(object):
|
||||||
self.name = '%s:%s' % (self.opts.app_label, self.opts.module_name)
|
self.name = '%s:%s' % (self.opts.app_label, self.opts.module_name)
|
||||||
self.var_name = self.opts.object_name.lower()
|
self.var_name = self.opts.object_name.lower()
|
||||||
|
|
||||||
|
def get_choices(self, include_blank=True, blank_choice=BLANK_CHOICE_DASH,
|
||||||
|
limit_to_currently_related=False):
|
||||||
|
"""Returns choices with a default blank choices included, for use
|
||||||
|
as SelectField choices for this field.
|
||||||
|
|
||||||
|
Analogue of django.db.models.fields.Field.get_choices, provided
|
||||||
|
initially for utilisation by RelatedFilterSpec.
|
||||||
|
"""
|
||||||
|
first_choice = include_blank and blank_choice or []
|
||||||
|
queryset = self.model._default_manager.all()
|
||||||
|
if limit_to_currently_related:
|
||||||
|
queryset = queryset.complex_filter(
|
||||||
|
{'%s__isnull' % self.parent_model._meta.module_name: False})
|
||||||
|
lst = [(x._get_pk_val(), smart_unicode(x)) for x in queryset]
|
||||||
|
return first_choice + lst
|
||||||
|
|
||||||
def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
|
def get_db_prep_lookup(self, lookup_type, value, connection, prepared=False):
|
||||||
# Defer to the actual field definition for db prep
|
# Defer to the actual field definition for db prep
|
||||||
return self.field.get_db_prep_lookup(lookup_type, value,
|
return self.field.get_db_prep_lookup(lookup_type, value,
|
||||||
|
|
|
@ -458,6 +458,11 @@ how both ``list_display`` and ``list_filter`` work::
|
||||||
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
|
list_display = ('username', 'email', 'first_name', 'last_name', 'is_staff')
|
||||||
list_filter = ('is_staff', 'is_superuser')
|
list_filter = ('is_staff', 'is_superuser')
|
||||||
|
|
||||||
|
Fields in ``list_filter`` can also span relations using the ``__`` lookup::
|
||||||
|
|
||||||
|
class UserAdminWithLookup(UserAdmin):
|
||||||
|
list_filter = ('groups__name')
|
||||||
|
|
||||||
The above code results in an admin change list page that looks like this:
|
The above code results in an admin change list page that looks like this:
|
||||||
|
|
||||||
.. image:: _images/users_changelist.png
|
.. image:: _images/users_changelist.png
|
||||||
|
|
|
@ -159,6 +159,8 @@ requests. These include:
|
||||||
<cache_key_prefixing>` and :ref:`transformation
|
<cache_key_prefixing>` and :ref:`transformation
|
||||||
<cache_key_transformation>` has been added to the cache API.
|
<cache_key_transformation>` has been added to the cache API.
|
||||||
|
|
||||||
|
* Support for lookups spanning relations in admin's ``list_filter``.
|
||||||
|
|
||||||
.. _backwards-incompatible-changes-1.3:
|
.. _backwards-incompatible-changes-1.3:
|
||||||
|
|
||||||
Backwards-incompatible changes in 1.3
|
Backwards-incompatible changes in 1.3
|
||||||
|
|
|
@ -32,3 +32,4 @@ site.register(models.Article, models.ArticleAdmin)
|
||||||
site.register(models.Section, inlines=[models.ArticleInline])
|
site.register(models.Section, inlines=[models.ArticleInline])
|
||||||
site.register(models.Thing, models.ThingAdmin)
|
site.register(models.Thing, models.ThingAdmin)
|
||||||
site.register(models.Fabric, models.FabricAdmin)
|
site.register(models.Fabric, models.FabricAdmin)
|
||||||
|
site.register(models.ChapterXtra1, models.ChapterXtra1Admin)
|
||||||
|
|
|
@ -0,0 +1,45 @@
|
||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<django-objects version="1.0">
|
||||||
|
<object pk="1" model="admin_views.book">
|
||||||
|
<field type="CharField" name="name">Book 1</field>
|
||||||
|
</object>
|
||||||
|
<object pk="2" model="admin_views.book">
|
||||||
|
<field type="CharField" name="name">Book 2</field>
|
||||||
|
</object>
|
||||||
|
<object pk="1" model="admin_views.promo">
|
||||||
|
<field type="CharField" name="name">Promo 1</field>
|
||||||
|
<field type="ForiegnKey" name="book">1</field>
|
||||||
|
</object>
|
||||||
|
<object pk="2" model="admin_views.promo">
|
||||||
|
<field type="CharField" name="name">Promo 2</field>
|
||||||
|
<field type="ForiegnKey" name="book">2</field>
|
||||||
|
</object>
|
||||||
|
<object pk="1" model="admin_views.chapter">
|
||||||
|
<field type="CharField" name="title">Chapter 1</field>
|
||||||
|
<field type="TextField" name="content">[ insert contents here ]</field>
|
||||||
|
<field type="ForiegnKey" name="book">1</field>
|
||||||
|
</object>
|
||||||
|
<object pk="2" model="admin_views.chapter">
|
||||||
|
<field type="CharField" name="title">Chapter 2</field>
|
||||||
|
<field type="TextField" name="content">[ insert contents here ]</field>
|
||||||
|
<field type="ForiegnKey" name="book">1</field>
|
||||||
|
</object>
|
||||||
|
<object pk="3" model="admin_views.chapter">
|
||||||
|
<field type="CharField" name="title">Chapter 1</field>
|
||||||
|
<field type="TextField" name="content">[ insert contents here ]</field>
|
||||||
|
<field type="ForiegnKey" name="book">2</field>
|
||||||
|
</object>
|
||||||
|
<object pk="4" model="admin_views.chapter">
|
||||||
|
<field type="CharField" name="title">Chapter 2</field>
|
||||||
|
<field type="TextField" name="content">[ insert contents here ]</field>
|
||||||
|
<field type="ForiegnKey" name="book">2</field>
|
||||||
|
</object>
|
||||||
|
<object pk="1" model="admin_views.chapterxtra1">
|
||||||
|
<field type="CharField" name="xtra">ChapterXtra1 1</field>
|
||||||
|
<field type="ForiegnKey" name="chap">1</field>
|
||||||
|
</object>
|
||||||
|
<object pk="2" model="admin_views.chapterxtra1">
|
||||||
|
<field type="CharField" name="xtra">ChapterXtra1 2</field>
|
||||||
|
<field type="ForiegnKey" name="chap">3</field>
|
||||||
|
</object>
|
||||||
|
</django-objects>
|
|
@ -90,6 +90,14 @@ class ArticleInline(admin.TabularInline):
|
||||||
class ChapterInline(admin.TabularInline):
|
class ChapterInline(admin.TabularInline):
|
||||||
model = Chapter
|
model = Chapter
|
||||||
|
|
||||||
|
class ChapterXtra1Admin(admin.ModelAdmin):
|
||||||
|
list_filter = ('chap',
|
||||||
|
'chap__title',
|
||||||
|
'chap__book',
|
||||||
|
'chap__book__name',
|
||||||
|
'chap__book__promo',
|
||||||
|
'chap__book__promo__name',)
|
||||||
|
|
||||||
class ArticleAdmin(admin.ModelAdmin):
|
class ArticleAdmin(admin.ModelAdmin):
|
||||||
list_display = ('content', 'date', callable_year, 'model_year', 'modeladmin_year')
|
list_display = ('content', 'date', callable_year, 'model_year', 'modeladmin_year')
|
||||||
list_filter = ('date',)
|
list_filter = ('date',)
|
||||||
|
@ -168,7 +176,7 @@ class Thing(models.Model):
|
||||||
return self.title
|
return self.title
|
||||||
|
|
||||||
class ThingAdmin(admin.ModelAdmin):
|
class ThingAdmin(admin.ModelAdmin):
|
||||||
list_filter = ('color',)
|
list_filter = ('color', 'color__warm', 'color__value')
|
||||||
|
|
||||||
class Fabric(models.Model):
|
class Fabric(models.Model):
|
||||||
NG_CHOICES = (
|
NG_CHOICES = (
|
||||||
|
@ -646,7 +654,7 @@ admin.site.register(CyclicTwo)
|
||||||
# contrib.admin.util's get_deleted_objects function.
|
# contrib.admin.util's get_deleted_objects function.
|
||||||
admin.site.register(Book, inlines=[ChapterInline])
|
admin.site.register(Book, inlines=[ChapterInline])
|
||||||
admin.site.register(Promo)
|
admin.site.register(Promo)
|
||||||
admin.site.register(ChapterXtra1)
|
admin.site.register(ChapterXtra1, ChapterXtra1Admin)
|
||||||
admin.site.register(Pizza, PizzaAdmin)
|
admin.site.register(Pizza, PizzaAdmin)
|
||||||
admin.site.register(Topping)
|
admin.site.register(Topping)
|
||||||
admin.site.register(Album)
|
admin.site.register(Album)
|
||||||
|
|
|
@ -19,6 +19,7 @@ from django.utils import formats
|
||||||
from django.utils.cache import get_max_age
|
from django.utils.cache import get_max_age
|
||||||
from django.utils.encoding import iri_to_uri
|
from django.utils.encoding import iri_to_uri
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
|
from django.utils.http import urlencode
|
||||||
from django.utils.translation import activate, deactivate
|
from django.utils.translation import activate, deactivate
|
||||||
from django.utils import unittest
|
from django.utils import unittest
|
||||||
|
|
||||||
|
@ -27,11 +28,12 @@ from models import Article, BarAccount, CustomArticle, EmptyModel, \
|
||||||
FooAccount, Gallery, ModelWithStringPrimaryKey, \
|
FooAccount, Gallery, ModelWithStringPrimaryKey, \
|
||||||
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
|
Person, Persona, Picture, Podcast, Section, Subscriber, Vodcast, \
|
||||||
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
|
Language, Collector, Widget, Grommet, DooHickey, FancyDoodad, Whatsit, \
|
||||||
Category, Post, Plot, FunkyTag
|
Category, Post, Plot, FunkyTag, Chapter, Book, Promo
|
||||||
|
|
||||||
|
|
||||||
class AdminViewBasicTest(TestCase):
|
class AdminViewBasicTest(TestCase):
|
||||||
fixtures = ['admin-views-users.xml', 'admin-views-colors.xml', 'admin-views-fabrics.xml']
|
fixtures = ['admin-views-users.xml', 'admin-views-colors.xml',
|
||||||
|
'admin-views-fabrics.xml', 'admin-views-books.xml']
|
||||||
|
|
||||||
# Store the bit of the URL where the admin is registered as a class
|
# Store the bit of the URL where the admin is registered as a class
|
||||||
# variable. That way we can test a second AdminSite just by subclassing
|
# variable. That way we can test a second AdminSite just by subclassing
|
||||||
|
@ -204,7 +206,9 @@ class AdminViewBasicTest(TestCase):
|
||||||
)
|
)
|
||||||
|
|
||||||
def testLimitedFilter(self):
|
def testLimitedFilter(self):
|
||||||
"""Ensure admin changelist filters do not contain objects excluded via limit_choices_to."""
|
"""Ensure admin changelist filters do not contain objects excluded via limit_choices_to.
|
||||||
|
This also tests relation-spanning filters (e.g. 'color__value').
|
||||||
|
"""
|
||||||
response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit)
|
response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit)
|
||||||
self.failUnlessEqual(response.status_code, 200)
|
self.failUnlessEqual(response.status_code, 200)
|
||||||
self.failUnless(
|
self.failUnless(
|
||||||
|
@ -216,6 +220,47 @@ class AdminViewBasicTest(TestCase):
|
||||||
"Changelist filter not correctly limited by limit_choices_to."
|
"Changelist filter not correctly limited by limit_choices_to."
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def testRelationSpanningFilters(self):
|
||||||
|
response = self.client.get('/test_admin/%s/admin_views/chapterxtra1/' %
|
||||||
|
self.urlbit)
|
||||||
|
self.failUnlessEqual(response.status_code, 200)
|
||||||
|
self.assertContains(response, '<div id="changelist-filter">')
|
||||||
|
filters = {
|
||||||
|
'chap__id__exact': dict(
|
||||||
|
values=[c.id for c in Chapter.objects.all()],
|
||||||
|
test=lambda obj, value: obj.chap.id == value),
|
||||||
|
'chap__title': dict(
|
||||||
|
values=[c.title for c in Chapter.objects.all()],
|
||||||
|
test=lambda obj, value: obj.chap.title == value),
|
||||||
|
'chap__book__id__exact': dict(
|
||||||
|
values=[b.id for b in Book.objects.all()],
|
||||||
|
test=lambda obj, value: obj.chap.book.id == value),
|
||||||
|
'chap__book__name': dict(
|
||||||
|
values=[b.name for b in Book.objects.all()],
|
||||||
|
test=lambda obj, value: obj.chap.book.name == value),
|
||||||
|
'chap__book__promo__id__exact': dict(
|
||||||
|
values=[p.id for p in Promo.objects.all()],
|
||||||
|
test=lambda obj, value:
|
||||||
|
obj.chap.book.promo_set.filter(id=value).exists()),
|
||||||
|
'chap__book__promo__name': dict(
|
||||||
|
values=[p.name for p in Promo.objects.all()],
|
||||||
|
test=lambda obj, value:
|
||||||
|
obj.chap.book.promo_set.filter(name=value).exists()),
|
||||||
|
}
|
||||||
|
for filter_path, params in filters.items():
|
||||||
|
for value in params['values']:
|
||||||
|
query_string = urlencode({filter_path: value})
|
||||||
|
# ensure filter link exists
|
||||||
|
self.assertContains(response, '<a href="?%s">' % query_string)
|
||||||
|
# ensure link works
|
||||||
|
filtered_response = self.client.get(
|
||||||
|
'/test_admin/%s/admin_views/chapterxtra1/?%s' % (
|
||||||
|
self.urlbit, query_string))
|
||||||
|
self.failUnlessEqual(filtered_response.status_code, 200)
|
||||||
|
# ensure changelist contains only valid objects
|
||||||
|
for obj in filtered_response.context['cl'].query_set.all():
|
||||||
|
self.assertTrue(params['test'](obj, value))
|
||||||
|
|
||||||
def testIncorrectLookupParameters(self):
|
def testIncorrectLookupParameters(self):
|
||||||
"""Ensure incorrect lookup parameters are handled gracefully."""
|
"""Ensure incorrect lookup parameters are handled gracefully."""
|
||||||
response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'notarealfield': '5'})
|
response = self.client.get('/test_admin/%s/admin_views/thing/' % self.urlbit, {'notarealfield': '5'})
|
||||||
|
|
|
@ -835,7 +835,7 @@ class ValidationTests(unittest.TestCase):
|
||||||
|
|
||||||
self.assertRaisesRegexp(
|
self.assertRaisesRegexp(
|
||||||
ImproperlyConfigured,
|
ImproperlyConfigured,
|
||||||
"'ValidationTestModelAdmin.list_filter\[0\]' refers to field 'non_existent_field' that is missing from model 'ValidationTestModel'.",
|
"'ValidationTestModelAdmin.list_filter\[0\]' refers to 'non_existent_field' which does not refer to a Field.",
|
||||||
validate,
|
validate,
|
||||||
ValidationTestModelAdmin,
|
ValidationTestModelAdmin,
|
||||||
ValidationTestModel,
|
ValidationTestModel,
|
||||||
|
|
Loading…
Reference in New Issue