Merge pull request #1281 from loic/ticket6903

Fixed #6903 - Preserved admin changelist filters.
This commit is contained in:
Aymeric Augustin 2013-06-18 13:07:28 -07:00
commit 9da9b3eb04
14 changed files with 317 additions and 42 deletions

View File

@ -13,6 +13,7 @@ from django.contrib.admin.util import (unquote, flatten_fieldsets, get_deleted_o
model_format_dict, NestedObjects, lookup_needs_distinct)
from django.contrib.admin import validation
from django.contrib.admin.templatetags.admin_static import static
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.contrib import messages
from django.views.decorators.csrf import csrf_protect
from django.core.exceptions import PermissionDenied, ValidationError, FieldError
@ -33,6 +34,7 @@ from django.utils.html import escape, escapejs
from django.utils.safestring import mark_safe
from django.utils import six
from django.utils.deprecation import RenameMethodsBase
from django.utils.http import urlencode
from django.utils.text import capfirst, get_text_list
from django.utils.translation import ugettext as _
from django.utils.translation import ungettext
@ -393,6 +395,7 @@ class ModelAdmin(BaseModelAdmin):
save_as = False
save_on_top = False
paginator = Paginator
preserve_filters = True
inlines = []
# Custom templates (designed to be over-ridden in subclasses)
@ -755,6 +758,27 @@ class ModelAdmin(BaseModelAdmin):
"""
return self.list_filter
def get_preserved_filters(self, request):
"""
Returns the preserved filters querystring.
"""
# FIXME: We can remove that getattr as soon as #20619 is fixed.
match = getattr(request, 'resolver_match', None)
if self.preserve_filters and match:
opts = self.model._meta
current_url = '%s:%s' % (match.namespace, match.url_name)
changelist_url = 'admin:%s_%s_changelist' % (opts.app_label, opts.model_name)
if current_url == changelist_url:
preserved_filters = request.GET.urlencode()
else:
preserved_filters = request.GET.get('_changelist_filters')
if preserved_filters:
return urlencode({'_changelist_filters': preserved_filters})
return ''
def construct_change_message(self, request, form, formsets):
"""
Construct a change message from a changed object.
@ -846,6 +870,8 @@ class ModelAdmin(BaseModelAdmin):
def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
opts = self.model._meta
app_label = opts.app_label
preserved_filters = self.get_preserved_filters(request)
form_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, form_url)
context.update({
'add': add,
'change': change,
@ -877,11 +903,19 @@ class ModelAdmin(BaseModelAdmin):
"""
opts = obj._meta
pk_value = obj._get_pk_val()
preserved_filters = self.get_preserved_filters(request)
msg_dict = {'name': force_text(opts.verbose_name), 'obj': force_text(obj)}
# Here, we distinguish between different save types by checking for
# the presence of keys in request.POST.
if "_continue" in request.POST:
if "_popup" in request.POST:
return HttpResponse(
'<!DOCTYPE html><html><head><title></title></head><body>'
'<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script></body></html>' % \
# escape() calls force_text.
(escape(pk_value), escapejs(obj)))
elif "_continue" in request.POST:
msg = _('The %(name)s "%(obj)s" was added successfully. You may edit it again below.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
if post_url_continue is None:
@ -889,20 +923,16 @@ class ModelAdmin(BaseModelAdmin):
(opts.app_label, opts.model_name),
args=(pk_value,),
current_app=self.admin_site.name)
if "_popup" in request.POST:
post_url_continue += "?_popup=1"
post_url_continue = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, post_url_continue)
return HttpResponseRedirect(post_url_continue)
if "_popup" in request.POST:
return HttpResponse(
'<!DOCTYPE html><html><head><title></title></head><body>'
'<script type="text/javascript">opener.dismissAddAnotherPopup(window, "%s", "%s");</script></body></html>' % \
# escape() calls force_text.
(escape(pk_value), escapejs(obj)))
elif "_addanother" in request.POST:
msg = _('The %(name)s "%(obj)s" was added successfully. You may add another %(name)s below.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
return HttpResponseRedirect(request.path)
redirect_url = request.path
redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url)
return HttpResponseRedirect(redirect_url)
else:
msg = _('The %(name)s "%(obj)s" was added successfully.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
@ -913,30 +943,36 @@ class ModelAdmin(BaseModelAdmin):
Determines the HttpResponse for the change_view stage.
"""
opts = self.model._meta
pk_value = obj._get_pk_val()
preserved_filters = self.get_preserved_filters(request)
msg_dict = {'name': force_text(opts.verbose_name), 'obj': force_text(obj)}
if "_continue" in request.POST:
msg = _('The %(name)s "%(obj)s" was changed successfully. You may edit it again below.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
if "_popup" in request.REQUEST:
return HttpResponseRedirect(request.path + "?_popup=1")
else:
return HttpResponseRedirect(request.path)
redirect_url = request.path
redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url)
return HttpResponseRedirect(redirect_url)
elif "_saveasnew" in request.POST:
msg = _('The %(name)s "%(obj)s" was added successfully. You may edit it again below.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
return HttpResponseRedirect(reverse('admin:%s_%s_change' %
(opts.app_label, opts.model_name),
args=(pk_value,),
current_app=self.admin_site.name))
redirect_url = reverse('admin:%s_%s_change' %
(opts.app_label, opts.model_name),
args=(pk_value,),
current_app=self.admin_site.name)
redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url)
return HttpResponseRedirect(redirect_url)
elif "_addanother" in request.POST:
msg = _('The %(name)s "%(obj)s" was changed successfully. You may add another %(name)s below.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
return HttpResponseRedirect(reverse('admin:%s_%s_add' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name))
redirect_url = reverse('admin:%s_%s_add' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name)
redirect_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, redirect_url)
return HttpResponseRedirect(redirect_url)
else:
msg = _('The %(name)s "%(obj)s" was changed successfully.') % msg_dict
self.message_user(request, msg, messages.SUCCESS)
@ -952,6 +988,8 @@ class ModelAdmin(BaseModelAdmin):
post_url = reverse('admin:%s_%s_changelist' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name)
preserved_filters = self.get_preserved_filters(request)
post_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, post_url)
else:
post_url = reverse('admin:index',
current_app=self.admin_site.name)
@ -963,10 +1001,13 @@ class ModelAdmin(BaseModelAdmin):
when editing an existing object.
"""
opts = self.model._meta
if self.has_change_permission(request, None):
post_url = reverse('admin:%s_%s_changelist' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name)
preserved_filters = self.get_preserved_filters(request)
post_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, post_url)
else:
post_url = reverse('admin:index',
current_app=self.admin_site.name)
@ -1122,6 +1163,7 @@ class ModelAdmin(BaseModelAdmin):
'inline_admin_formsets': inline_admin_formsets,
'errors': helpers.AdminErrorList(form, formsets),
'app_label': opts.app_label,
'preserved_filters': self.get_preserved_filters(request),
}
context.update(extra_context or {})
return self.render_change_form(request, context, form_url=form_url, add=True)
@ -1214,6 +1256,7 @@ class ModelAdmin(BaseModelAdmin):
'inline_admin_formsets': inline_admin_formsets,
'errors': helpers.AdminErrorList(form, formsets),
'app_label': opts.app_label,
'preserved_filters': self.get_preserved_filters(request),
}
context.update(extra_context or {})
return self.render_change_form(request, context, change=True, obj=obj, form_url=form_url)
@ -1357,11 +1400,13 @@ class ModelAdmin(BaseModelAdmin):
'cl': cl,
'media': media,
'has_add_permission': self.has_add_permission(request),
'opts': cl.opts,
'app_label': app_label,
'action_form': action_form,
'actions_on_top': self.actions_on_top,
'actions_on_bottom': self.actions_on_bottom,
'actions_selection_counter': self.actions_selection_counter,
'preserved_filters': self.get_preserved_filters(request),
}
context.update(extra_context or {})
@ -1406,12 +1451,16 @@ class ModelAdmin(BaseModelAdmin):
'obj': force_text(obj_display)},
messages.SUCCESS)
if not self.has_change_permission(request, None):
return HttpResponseRedirect(reverse('admin:index',
current_app=self.admin_site.name))
return HttpResponseRedirect(reverse('admin:%s_%s_changelist' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name))
if self.has_change_permission(request, None):
post_url = reverse('admin:%s_%s_changelist' %
(opts.app_label, opts.model_name),
current_app=self.admin_site.name)
preserved_filters = self.get_preserved_filters(request)
post_url = add_preserved_filters({'preserved_filters': preserved_filters, 'opts': opts}, post_url)
else:
post_url = reverse('admin:index',
current_app=self.admin_site.name)
return HttpResponseRedirect(post_url)
object_name = force_text(opts.verbose_name)
@ -1429,6 +1478,7 @@ class ModelAdmin(BaseModelAdmin):
"protected": protected,
"opts": opts,
"app_label": app_label,
'preserved_filters': self.get_preserved_filters(request),
}
context.update(extra_context or {})
@ -1463,6 +1513,7 @@ class ModelAdmin(BaseModelAdmin):
'object': obj,
'app_label': app_label,
'opts': opts,
'preserved_filters': self.get_preserved_filters(request),
}
context.update(extra_context or {})
return TemplateResponse(request, self.object_history_template or [

View File

@ -1,6 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_static admin_modify %}
{% load admin_urls %}
{% load i18n admin_urls admin_static admin_modify %}
{% block extrahead %}{{ block.super }}
<script type="text/javascript" src="{% url 'admin:jsi18n' %}"></script>
@ -29,7 +28,10 @@
{% if change %}{% if not is_popup %}
<ul class="object-tools">
{% block object-tools-items %}
<li><a href="{% url opts|admin_urlname:'history' original.pk|admin_urlquote %}" class="historylink">{% trans "History" %}</a></li>
<li>
{% url opts|admin_urlname:'history' original.pk|admin_urlquote as history_url %}
<a href="{% add_preserved_filters history_url %}" class="historylink">{% trans "History" %}</a>
</li>
{% if has_absolute_url %}<li><a href="{% url 'admin:view_on_site' content_type_id original.pk %}" class="viewsitelink">{% trans "View on site" %}</a></li>{% endif%}
{% endblock %}
</ul>

View File

@ -1,6 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n admin_static admin_list %}
{% load admin_urls %}
{% load i18n admin_urls admin_static admin_list %}
{% block extrastyle %}
{{ block.super }}
@ -54,7 +53,8 @@
<ul class="object-tools">
{% block object-tools-items %}
<li>
<a href="{% url cl.opts|admin_urlname:'add' %}{% if is_popup %}?_popup=1{% endif %}" class="addlink">
{% url cl.opts|admin_urlname:'add' as add_url %}
<a href="{% add_preserved_filters add_url is_popup %}" class="addlink">
{% blocktrans with cl.opts.verbose_name as name %}Add {{ name }}{% endblocktrans %}
</a>
</li>

View File

@ -1,6 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% load admin_urls %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">

View File

@ -1,6 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n l10n %}
{% load admin_urls %}
{% load i18n l10n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">

View File

@ -1,6 +1,5 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% load admin_urls %}
{% load i18n admin_urls %}
{% block breadcrumbs %}
<div class="breadcrumbs">

View File

@ -1,7 +1,10 @@
{% load i18n admin_urls %}
<div class="submit-row">
{% if show_save %}<input type="submit" value="{% trans 'Save' %}" class="default" name="_save" />{% endif %}
{% if show_delete_link %}<p class="deletelink-box"><a href="{% url opts|admin_urlname:'delete' original.pk|admin_urlquote %}" class="deletelink">{% trans "Delete" %}</a></p>{% endif %}
{% if show_delete_link %}
{% url opts|admin_urlname:'delete' original.pk|admin_urlquote as delete_url %}
<p class="deletelink-box"><a href="{% add_preserved_filters delete_url %}" class="deletelink">{% trans "Delete" %}</a></p>
{% endif %}
{% if show_save_as_new %}<input type="submit" value="{% trans 'Save as new' %}" name="_saveasnew" />{%endif%}
{% if show_save_and_add_another %}<input type="submit" value="{% trans 'Save and add another' %}" name="_addanother" />{% endif %}
{% if show_save_and_continue %}<input type="submit" value="{% trans 'Save and continue editing' %}" name="_continue" />{% endif %}

View File

@ -2,6 +2,7 @@ from __future__ import unicode_literals
import datetime
from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
from django.contrib.admin.util import (lookup_field, display_for_field,
display_for_value, label_for_field)
from django.contrib.admin.views.main import (ALL_VAR, EMPTY_CHANGELIST_VALUE,
@ -217,6 +218,7 @@ def items_for_result(cl, result, form):
table_tag = {True:'th', False:'td'}[first]
first = False
url = cl.url_for_result(result)
url = add_preserved_filters({'preserved_filters': cl.preserved_filters, 'opts': cl.opts}, url)
# Convert the pk to something that can be used in Javascript.
# Problem cases are long ints (23L) and non-ASCII strings.
if cl.to_field:

View File

@ -37,7 +37,8 @@ def submit_row(context):
not is_popup and (not save_as or context['add']),
'show_save_and_continue': not is_popup and context['has_change_permission'],
'is_popup': is_popup,
'show_save': True
'show_save': True,
'preserved_filters': context.get('preserved_filters'),
}
if context.get('original') is not None:
ctx['original'] = context['original']

View File

@ -1,8 +1,17 @@
from django.utils.http import urlencode
try:
from urllib.parse import parse_qsl, urlparse, urlunparse
except ImportError:
from urlparse import parse_qsl, urlparse, urlunparse
from django import template
from django.contrib.admin.util import quote
from django.core.urlresolvers import resolve, Resolver404
register = template.Library()
@register.filter
def admin_urlname(value, arg):
return 'admin:%s_%s_%s' % (value.app_label, value.model_name, arg)
@ -11,3 +20,36 @@ def admin_urlname(value, arg):
@register.filter
def admin_urlquote(value):
return quote(value)
@register.simple_tag(takes_context=True)
def add_preserved_filters(context, url, popup=False):
opts = context.get('opts')
preserved_filters = context.get('preserved_filters')
parsed_url = list(urlparse(url))
parsed_qs = dict(parse_qsl(parsed_url[4]))
merged_qs = dict()
if opts and preserved_filters:
preserved_filters = dict(parse_qsl(preserved_filters))
try:
match = resolve(url)
except Resolver404:
pass
else:
current_url = '%s:%s' % (match.namespace, match.url_name)
changelist_url = 'admin:%s_%s_changelist' % (opts.app_label, opts.model_name)
if changelist_url == current_url and '_changelist_filters' in preserved_filters:
preserved_filters = dict(parse_qsl(preserved_filters['_changelist_filters']))
merged_qs.update(preserved_filters)
if popup:
merged_qs['_popup'] = 1
merged_qs.update(parsed_qs)
parsed_url[4] = urlencode(merged_qs)
return urlunparse(parsed_url)

View File

@ -59,6 +59,7 @@ class ChangeList(six.with_metaclass(RenameChangeListMethods)):
self.list_per_page = list_per_page
self.list_max_show_all = list_max_show_all
self.model_admin = model_admin
self.preserved_filters = model_admin.get_preserved_filters(request)
# Get search parameters from the query string.
try:

View File

@ -870,6 +870,14 @@ subclass::
``prepopulated_fields`` doesn't accept ``DateTimeField``, ``ForeignKey``,
nor ``ManyToManyField`` fields.
.. attribute:: ModelAdmin.preserve_filters
.. versionadded:: 1.6
The admin now preserves filters on the list view after creating, editing
or deleting an object. You can restore the previous behavior of clearing
filters by setting this attribute to ``False``.
.. attribute:: ModelAdmin.radio_fields
By default, Django's admin uses a select-box interface (<select>) for

View File

@ -325,6 +325,11 @@ Minor features
:ref:`see the updated recommendation <raising-validation-error>` for raising
a ``ValidationError``.
* :class:`~django.contrib.admin.ModelAdmin` now preserves filters on the list view
after creating, editing or deleting an object. It's possible to restore the previous
behavior of clearing filters by setting the
:attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``.
Backwards incompatible changes in 1.6
=====================================
@ -634,6 +639,16 @@ will render something like:
If you want to keep the current behavior of rendering ``label_tag`` without
the ``label_suffix``, instantiate the form ``label_suffix=''``.
Admin views ``_changelist_filters`` GET parameter
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
To achieve preserving and restoring list view filters, admin views now
pass around the `_changelist_filters` GET parameter. It's important that you
account for that change if you have custom admin templates or if your tests
rely on the previous URLs. If you want to revert to the original behavior you
can set the
:attr:`~django.contrib.admin.ModelAdmin.preserve_filters` attribute to ``False``.
Miscellaneous
~~~~~~~~~~~~~

View File

@ -4157,3 +4157,156 @@ class AdminUserMessageTest(TestCase):
self.assertContains(response,
'<li class="extra_tag info">Test tags</li>',
html=True)
@override_settings(PASSWORD_HASHERS=('django.contrib.auth.hashers.SHA1PasswordHasher',))
class AdminKeepChangeListFiltersTests(TestCase):
urls = "admin_views.urls"
fixtures = ['admin-views-users.xml']
def setUp(self):
self.client.login(username='super', password='secret')
def tearDown(self):
self.client.logout()
def get_changelist_filters_querystring(self):
return urlencode({
'is_superuser__exact': 0,
'is_staff__exact': 0,
})
def get_preserved_filters_querystring(self):
return urlencode({
'_changelist_filters': self.get_changelist_filters_querystring()
})
def get_sample_user_id(self):
return 104
def get_changelist_url(self):
return '%s?%s' % (
reverse('admin:auth_user_changelist'),
self.get_changelist_filters_querystring(),
)
def get_add_url(self):
return '%s?%s' % (
reverse('admin:auth_user_add'),
self.get_preserved_filters_querystring(),
)
def get_change_url(self, user_id=None):
if user_id is None:
user_id = self.get_sample_user_id()
return "%s?%s" % (
reverse('admin:auth_user_change', args=(user_id,)),
self.get_preserved_filters_querystring(),
)
def get_history_url(self, user_id=None):
if user_id is None:
user_id = self.get_sample_user_id()
return "%s?%s" % (
reverse('admin:auth_user_history', args=(user_id,)),
self.get_preserved_filters_querystring(),
)
def get_delete_url(self, user_id=None):
if user_id is None:
user_id = self.get_sample_user_id()
return "%s?%s" % (
reverse('admin:auth_user_delete', args=(user_id,)),
self.get_preserved_filters_querystring(),
)
def test_changelist_view(self):
response = self.client.get(self.get_changelist_url())
self.assertEqual(response.status_code, 200)
# Check the `change_view` link has the correct querystring.
detail_link = """<a href="%s">joepublic</a>""" % self.get_change_url()
self.assertContains(response, detail_link, count=1)
def test_change_view(self):
# Get the `change_view`.
response = self.client.get(self.get_change_url())
self.assertEqual(response.status_code, 200)
# Check the form action.
form_action = """<form enctype="multipart/form-data" action="?%s" method="post" id="user_form">""" % self.get_preserved_filters_querystring()
self.assertContains(response, form_action, count=1)
# Check the history link.
history_link = """<a href="%s" class="historylink">History</a>""" % self.get_history_url()
self.assertContains(response, history_link, count=1)
# Check the delete link.
delete_link = """<a href="%s" class="deletelink">Delete</a>""" % (self.get_delete_url())
self.assertContains(response, delete_link, count=1)
# Test redirect on "Save".
post_data = {
'username': 'joepublic',
'last_login_0': '2007-05-30',
'last_login_1': '13:20:10',
'date_joined_0': '2007-05-30',
'date_joined_1': '13:20:10',
}
post_data['_save'] = 1
response = self.client.post(self.get_change_url(), data=post_data)
self.assertRedirects(response, self.get_changelist_url())
post_data.pop('_save')
# Test redirect on "Save and continue".
post_data['_continue'] = 1
response = self.client.post(self.get_change_url(), data=post_data)
self.assertRedirects(response, self.get_change_url())
post_data.pop('_continue')
# Test redirect on "Save and add new".
post_data['_addanother'] = 1
response = self.client.post(self.get_change_url(), data=post_data)
self.assertRedirects(response, self.get_add_url())
post_data.pop('_addanother')
def test_add_view(self):
# Get the `add_view`.
response = self.client.get(self.get_add_url())
self.assertEqual(response.status_code, 200)
# Check the form action.
form_action = """<form enctype="multipart/form-data" action="?%s" method="post" id="user_form">""" % self.get_preserved_filters_querystring()
self.assertContains(response, form_action, count=1)
# Test redirect on "Save".
post_data = {
'username': 'dummy',
'password1': 'test',
'password2': 'test',
}
post_data['_save'] = 1
response = self.client.post(self.get_add_url(), data=post_data)
self.assertRedirects(response, self.get_change_url(self.get_sample_user_id() + 1))
post_data.pop('_save')
# Test redirect on "Save and continue".
post_data['username'] = 'dummy2'
post_data['_continue'] = 1
response = self.client.post(self.get_add_url(), data=post_data)
self.assertRedirects(response, self.get_change_url(self.get_sample_user_id() + 2))
post_data.pop('_continue')
# Test redirect on "Save and add new".
post_data['username'] = 'dummy3'
post_data['_addanother'] = 1
response = self.client.post(self.get_add_url(), data=post_data)
self.assertRedirects(response, self.get_add_url())
post_data.pop('_addanother')
def test_delete_view(self):
# Test redirect on "Delete".
response = self.client.post(self.get_delete_url(), {'post': 'yes'})
self.assertRedirects(response, self.get_changelist_url())