1
0
mirror of https://github.com/django/django.git synced 2024-12-22 17:16:24 +00:00

Fixed #10505: added support for bulk admin actions, including a globally-available "delete selected" action. See the documentation for details.

This work started life as Brian Beck's "django-batchadmin." It was rewritten for inclusion in Django by Alex Gaynor, Jannis Leidel (jezdez), and Martin Mahner (bartTC). Thanks, guys!

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10121 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
Jacob Kaplan-Moss 2009-03-23 20:22:56 +00:00
parent 4e2533436e
commit 44f3080226
26 changed files with 881 additions and 108 deletions

View File

@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
Ned Batchelder <http://www.nedbatchelder.com/>
batiste@dosimple.ch
Batman
Brian Beck <http://blog.brianbeck.com/>
Shannon -jj Behrens <http://jjinux.blogspot.com/>
Esdras Beleza <linux@esdrasbeleza.com>
Chris Bennett <chrisrbennett@yahoo.com>
@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better:
Daniel Lindsley <polarcowz@gmail.com>
Trey Long <trey@ktrl.com>
msaelices <msaelices@gmail.com>
Martin Mahner <http://www.mahner.org/>
Matt McClanahan <http://mmcc.cx/>
Frantisek Malina <vizualbod@vizualbod.com>
Martin Maney <http://www.chipy.org/Martin_Maney>

View File

@ -1,3 +1,4 @@
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
from django.contrib.admin.options import StackedInline, TabularInline
from django.contrib.admin.sites import AdminSite, site

View File

@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
from django.utils.encoding import force_unicode
from django.contrib.admin.util import flatten_fieldsets
from django.contrib.contenttypes.models import ContentType
from django.utils.translation import ugettext_lazy as _
ACTION_CHECKBOX_NAME = '_selected_action'
class ActionForm(forms.Form):
action = forms.ChoiceField(label=_('Action:'))
checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
class AdminForm(object):
def __init__(self, form, fieldsets, prepopulated_fields):

View File

@ -50,12 +50,24 @@
#changelist table thead th {
white-space: nowrap;
vertical-align: middle;
}
#changelist table thead th:first-child {
width: 1.5em;
text-align: center;
}
#changelist table tbody td {
border-left: 1px solid #ddd;
}
#changelist table tbody td:first-child {
border-left: 0;
border-right: 1px solid #ddd;
text-align: center;
}
#changelist table tfoot {
color: #666;
}
@ -209,3 +221,35 @@
border-color: #036;
}
/* ACTIONS */
.filtered .actions {
margin-right: 160px !important;
border-right: 1px solid #ddd;
}
#changelist .actions {
color: #666;
padding: 3px;
border-bottom: 1px solid #ddd;
background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
}
#changelist .actions:last-child {
border-bottom: none;
}
#changelist .actions select {
border: 1px solid #aaa;
margin: 0 0.5em;
padding: 1px 2px;
}
#changelist .actions label {
font-size: 11px;
margin: 0 0.5em;
}
#changelist #action-toggle {
display: none;
}

View File

@ -0,0 +1,19 @@
var Actions = {
init: function() {
selectAll = document.getElementById('action-toggle');
if (selectAll) {
selectAll.style.display = 'inline';
addEvent(selectAll, 'change', function() {
Actions.checker(this.checked);
});
}
},
checker: function(checked) {
actionCheckboxes = document.getElementsBySelector('tr input.action-select');
for(var i = 0; i < actionCheckboxes.length; i++) {
actionCheckboxes[i].checked = checked;
}
}
}
addEvent(window, 'load', Actions.init);

View File

@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
from django.contrib.contenttypes.models import ContentType
from django.contrib.admin import widgets
from django.contrib.admin import helpers
from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
from django.core.exceptions import PermissionDenied
from django.db import models, transaction
from django.db.models.fields import BLANK_CHOICE_DASH
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render_to_response
from django.utils.functional import update_wrapper
@ -16,7 +17,7 @@ from django.utils.safestring import mark_safe
from django.utils.functional import curry
from django.utils.text import capfirst, get_text_list
from django.utils.translation import ugettext as _
from django.utils.translation import ngettext
from django.utils.translation import ngettext, ugettext_lazy
from django.utils.encoding import force_unicode
try:
set
@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
delete_confirmation_template = None
object_history_template = None
# Actions
actions = ['delete_selected']
action_form = helpers.ActionForm
actions_on_top = True
actions_on_bottom = False
def __init__(self, model, admin_site):
self.model = model
self.opts = model._meta
@ -200,6 +207,13 @@ class ModelAdmin(BaseModelAdmin):
for inline_class in self.inlines:
inline_instance = inline_class(self.model, self.admin_site)
self.inline_instances.append(inline_instance)
if 'action_checkbox' not in self.list_display:
self.list_display = ['action_checkbox'] + list(self.list_display)
if not self.list_display_links:
for name in self.list_display:
if name != 'action_checkbox':
self.list_display_links = [name]
break
super(ModelAdmin, self).__init__()
def get_urls(self):
@ -239,6 +253,8 @@ class ModelAdmin(BaseModelAdmin):
from django.conf import settings
js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
if self.actions:
js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
if self.prepopulated_fields:
js.append('js/urlify.js')
if self.opts.get_ordered_objects():
@ -390,6 +406,121 @@ class ModelAdmin(BaseModelAdmin):
action_flag = DELETION
)
def action_checkbox(self, obj):
"""
A list_display column containing a checkbox widget.
"""
return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
action_checkbox.allow_tags = True
def get_actions(self, request=None):
"""
Return a dictionary mapping the names of all actions for this
ModelAdmin to a tuple of (callable, name, description) for each action.
"""
actions = {}
for klass in [self.admin_site] + self.__class__.mro()[::-1]:
for action in getattr(klass, 'actions', []):
func, name, description = self.get_action(action)
actions[name] = (func, name, description)
return actions
def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
"""
Return a list of choices for use in a form object. Each choice is a
tuple (name, description).
"""
choices = [] + default_choices
for func, name, description in self.get_actions(request).itervalues():
choice = (name, description % model_format_dict(self.opts))
choices.append(choice)
return choices
def get_action(self, action):
"""
Return a given action from a parameter, which can either be a calable,
or the name of a method on the ModelAdmin. Return is a tuple of
(callable, name, description).
"""
if callable(action):
func = action
action = action.__name__
elif hasattr(self, action):
func = getattr(self, action)
if hasattr(func, 'short_description'):
description = func.short_description
else:
description = capfirst(action.replace('_', ' '))
return func, action, description
def delete_selected(self, request, queryset):
"""
Default action which deletes the selected objects.
In the first step, it displays a confirmation page whichs shows all
the deleteable objects or, if the user has no permission one of the
related childs (foreignkeys) it displays a "permission denied" message.
In the second step delete all selected objects and display the change
list again.
"""
opts = self.model._meta
app_label = opts.app_label
# Check that the user has delete permission for the actual model
if not self.has_delete_permission(request):
raise PermissionDenied
# Populate deletable_objects, a data structure of all related objects that
# will also be deleted.
# deletable_objects must be a list if we want to use '|unordered_list' in the template
deletable_objects = []
perms_needed = set()
i = 0
for obj in queryset:
deletable_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, self.admin_site, levels_to_root=2)
i=i+1
# The user has already confirmed the deletion.
# Do the deletion and return a None to display the change list view again.
if request.POST.get('post'):
if perms_needed:
raise PermissionDenied
n = queryset.count()
if n:
for obj in queryset:
obj_display = force_unicode(obj)
self.log_deletion(request, obj, obj_display)
queryset.delete()
self.message_user(request, _("Successfully deleted %d %s.") % (
n, model_ngettext(self.opts, n)
))
# Return None to display the change list page again.
return None
context = {
"title": _("Are you sure?"),
"object_name": force_unicode(opts.verbose_name),
"deletable_objects": deletable_objects,
'queryset': queryset,
"perms_lacking": perms_needed,
"opts": opts,
"root_path": self.admin_site.root_path,
"app_label": app_label,
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
}
# Display the confirmation page
return render_to_response(self.delete_confirmation_template or [
"admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
"admin/%s/delete_selected_confirmation.html" % app_label,
"admin/delete_selected_confirmation.html"
], context, context_instance=template.RequestContext(request))
delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
def construct_change_message(self, request, form, formsets):
"""
@ -529,6 +660,48 @@ class ModelAdmin(BaseModelAdmin):
self.message_user(request, msg)
return HttpResponseRedirect("../")
def response_action(self, request, queryset):
"""
Handle an admin action. This is called if a request is POSTed to the
changelist; it returns an HttpResponse if the action was handled, and
None otherwise.
"""
# There can be multiple action forms on the page (at the top
# and bottom of the change list, for example). Get the action
# whose button was pushed.
try:
action_index = int(request.POST.get('index', 0))
except ValueError:
action_index = 0
# Construct the action form.
data = request.POST.copy()
data.pop(helpers.ACTION_CHECKBOX_NAME, None)
data.pop("index", None)
action_form = self.action_form(data, auto_id=None)
action_form.fields['action'].choices = self.get_action_choices(request)
# If the form's valid we can handle the action.
if action_form.is_valid():
action = action_form.cleaned_data['action']
func, name, description = self.get_actions(request)[action]
# Get the list of selected PKs. If nothing's selected, we can't
# perform an action on it, so bail.
selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
if not selected:
return None
response = func(request, queryset.filter(pk__in=selected))
# Actions may return an HttpResponse, which will be used as the
# response from the POST. If not, we'll be a good little HTTP
# citizen and redirect back to the changelist page.
if isinstance(response, HttpResponse):
return response
else:
return HttpResponseRedirect(".")
def add_view(self, request, form_url='', extra_context=None):
"The 'add' admin view for this model."
model = self.model
@ -721,6 +894,14 @@ class ModelAdmin(BaseModelAdmin):
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
# If the request was POSTed, this might be a bulk action or a bulk edit.
# Try to look up an action first, but if this isn't an action the POST
# will fall through to the bulk edit check, below.
if request.method == 'POST':
response = self.response_action(request, queryset=cl.get_query_set())
if response:
return response
# If we're allowing changelist editing, we need to construct a formset
# for the changelist given all the fields to be edited. Then we'll
# use the formset to validate/process POSTed data.
@ -764,7 +945,11 @@ class ModelAdmin(BaseModelAdmin):
if formset:
media = self.media + formset.media
else:
media = None
media = self.media
# Build the action form and populate it with available actions.
action_form = self.action_form(auto_id=None)
action_form.fields['action'].choices = self.get_action_choices(request)
context = {
'title': cl.title,
@ -774,6 +959,9 @@ class ModelAdmin(BaseModelAdmin):
'has_add_permission': self.has_add_permission(request),
'root_path': self.admin_site.root_path,
'app_label': app_label,
'action_form': action_form,
'actions_on_top': self.actions_on_top,
'actions_on_bottom': self.actions_on_bottom,
}
context.update(extra_context or {})
return render_to_response(self.change_list_template or [

View File

@ -45,6 +45,8 @@ class AdminSite(object):
name += '_'
self.name = name
self.actions = []
def register(self, model_or_iterable, admin_class=None, **options):
"""
Registers the given model(s) with the given admin class.
@ -100,6 +102,11 @@ class AdminSite(object):
raise NotRegistered('The model %s is not registered' % model.__name__)
del self._registry[model]
def add_action(self, action):
if not callable(action):
raise TypeError("You can only register callable actions through an admin site")
self.actions.append(action)
def has_permission(self, request):
"""
Returns True if the given HttpRequest has permission to view

View File

@ -0,0 +1,5 @@
{% load i18n %}
<div class="actions">
{% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
<button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
</div>

View File

@ -7,8 +7,8 @@
{% if cl.formset %}
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
<script type="text/javascript" src="../../jsi18n/"></script>
{{ media }}
{% endif %}
{{ media }}
{% endblock %}
{% block bodyclass %}change-list{% endblock %}
@ -63,14 +63,18 @@
{% endif %}
{% endblock %}
<form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
{% if cl.formset %}
<form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
{{ cl.formset.management_form }}
{% endif %}
{% block result_list %}{% result_list cl %}{% endblock %}
{% block result_list %}
{% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
{% result_list cl %}
{% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
{% endblock %}
{% block pagination %}{% pagination cl %}{% endblock %}
{% if cl.formset %}</form>{% endif %}
</form>
</div>
</div>
{% endblock %}

View File

@ -0,0 +1,37 @@
{% extends "admin/base_site.html" %}
{% load i18n %}
{% block breadcrumbs %}
<div class="breadcrumbs">
<a href="../../">{% trans "Home" %}</a> &rsaquo;
<a href="../">{{ app_label|capfirst }}</a> &rsaquo;
<a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
{% trans 'Delete multiple objects' %}
</div>
{% endblock %}
{% block content %}
{% if perms_lacking %}
<p>{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
<ul>
{% for obj in perms_lacking %}
<li>{{ obj }}</li>
{% endfor %}
</ul>
{% else %}
<p>{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and it's related items will be deleted:{% endblocktrans %}</p>
{% for deleteable_object in deletable_objects %}
<ul>{{ deleteable_object|unordered_list }}</ul>
{% endfor %}
<form action="" method="post">
<div>
{% for obj in queryset %}
<input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
{% endfor %}
<input type="hidden" name="action" value="delete_selected" />
<input type="hidden" name="post" value="yes" />
<input type="submit" value="{% trans "Yes, I'm sure" %}" />
</div>
</form>
{% endif %}
{% endblock %}

View File

@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
def admin_list_filter(cl, spec):
return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
def admin_actions(context):
"""
Track the number of times the action field has been rendered on the page,
so we know which value to use.
"""
context['action_index'] = context.get('action_index', -1) + 1
return context
admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)

View File

@ -4,7 +4,8 @@ from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.text import capfirst
from django.utils.encoding import force_unicode
from django.utils.translation import ugettext as _
from django.utils.translation import ungettext, ugettext as _
from django.core.urlresolvers import reverse, NoReverseMatch
def quote(s):
"""
@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
current = current[-1]
current.append(val)
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
"Helper function that recursively populates deleted_objects."
def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
"""
Returns the url to the admin change view for the given app_label,
module_name and primary key.
"""
try:
return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
except NoReverseMatch:
return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
"""
Helper function that recursively populates deleted_objects.
`levels_to_root` defines the number of directories (../) to reach the
admin root path. In a change_view this is 4, in a change_list view 2.
This is for backwards compatibility since the options.delete_selected
method uses this function also from a change_list view.
This will not be used if we can reverse the URL.
"""
nh = _nest_help # Bind to local variable for performance
if current_depth > 16:
return # Avoid recursing too deep.
@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
else:
# Display a link to the admin page.
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
(escape(capfirst(related.opts.verbose_name)),
related.opts.app_label,
related.opts.object_name.lower(),
sub_obj._get_pk_val(),
get_change_view_url(related.opts.app_label,
related.opts.object_name.lower(),
sub_obj._get_pk_val(),
admin_site,
levels_to_root),
escape(sub_obj))), []])
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
else:
@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
else:
# Display a link to the admin page.
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
(escape(capfirst(related.opts.verbose_name)),
related.opts.app_label,
related.opts.object_name.lower(),
sub_obj._get_pk_val(),
get_change_view_url(related.opts.app_label,
related.opts.object_name.lower(),
sub_obj._get_pk_val(),
admin_site,
levels_to_root),
escape(sub_obj))), []])
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
# If there were related objects, and the user doesn't have
@ -147,11 +171,52 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
# Display a link to the admin page.
nh(deleted_objects, current_depth, [
mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
(u' <a href="../../../../%s/%s/%s/">%s</a>' % \
(related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
(u' <a href="%s">%s</a>' % \
(get_change_view_url(related.opts.app_label,
related.opts.object_name.lower(),
sub_obj._get_pk_val(),
admin_site,
levels_to_root),
escape(sub_obj)))), []])
# If there were related objects, and the user doesn't have
# permission to change them, add the missing perm to perms_needed.
if has_admin and has_related_objs:
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
if not user.has_perm(p):
perms_needed.add(related.opts.verbose_name)
def model_format_dict(obj):
"""
Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
typically for use with string formatting.
`obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
"""
if isinstance(obj, (models.Model, models.base.ModelBase)):
opts = obj._meta
elif isinstance(obj, models.query.QuerySet):
opts = obj.model._meta
else:
opts = obj
return {
'verbose_name': force_unicode(opts.verbose_name),
'verbose_name_plural': force_unicode(opts.verbose_name_plural)
}
def model_ngettext(obj, n=None):
"""
Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
depending on the count `n`.
`obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
If `obj` is a `QuerySet` instance, `n` is optional and the length of the
`QuerySet` is used.
"""
if isinstance(obj, models.query.QuerySet):
if n is None:
n = obj.count()
obj = obj.model
d = model_format_dict(obj)
return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)

View File

@ -127,6 +127,14 @@ def validate(cls, model):
continue
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
if cls.actions:
check_isseq(cls, 'actions', cls.actions)
for idx, item in enumerate(cls.actions):
if (not callable(item)) and (not hasattr(cls, item)):
raise ImproperlyConfigured("'%s.actions[%d]' is neither a "
"callable nor a method on %s" % (cls.__name__, idx, cls.__name__))
# list_select_related = False
# save_as = False
# save_on_top = False
@ -135,6 +143,7 @@ def validate(cls, model):
raise ImproperlyConfigured("'%s.%s' should be a boolean."
% (cls.__name__, attr))
# inlines = []
if hasattr(cls, 'inlines'):
check_isseq(cls, 'inlines', cls.inlines)

View File

@ -78,7 +78,7 @@ The development process
Other batteries included
========================
* :ref:`Admin site <ref-contrib-admin>`
* :ref:`Admin site <ref-contrib-admin>` | :ref:`Admin actions <ref-contrib-admin-actions>`
* :ref:`Authentication <topics-auth>`
* :ref:`Cache system <topics-cache>`
* :ref:`Conditional content processing <topics-conditional-processing>`

Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

View File

Before

Width:  |  Height:  |  Size: 41 KiB

After

Width:  |  Height:  |  Size: 41 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

View File

Before

Width:  |  Height:  |  Size: 58 KiB

After

Width:  |  Height:  |  Size: 58 KiB

View File

@ -0,0 +1,239 @@
.. _ref-contrib-admin-actions:
=============
Admin actions
=============
.. versionadded:: 1.1
.. currentmodule:: django.contrib.admin
The basic workflow of Django's admin is, in a nutshell, "select an object,
then change it." This works well for a majority of use cases. However, if you
need to make the same change to many objects at once, this workflow can be
quite tedious.
In these cases, Django's admin lets you write and register "actions" -- simple
functions that get called with a list of objects selected on the change list
page.
If you look at any change list in the admin, you'll see this feature in
action; Django ships with a "delete selected objects" action available to all
models. For example, here's the user module from Django's built-in
:mod:`django.contrib.auth` app:
.. image:: _images/user_actions.png
Read on to find out how to add your own actions to this list.
Writing actions
===============
The easiest way to explain actions is by example, so let's dive in.
A common use case for admin actions is the bulk updating of a model. Imagine a simple
news application with an ``Article`` model::
from django.db import models
STATUS_CHOICES = (
('d', 'Draft'),
('p', 'Published'),
('w', 'Withdrawn'),
)
class Article(models.Model):
title = models.CharField(max_length=100)
body = models.TextField()
status = models.CharField(max_length=1, choices=STATUS_CHOICES)
def __unicode__(self):
return self.title
A common task we might perform with a model like this is to update an
article's status from "draft" to "published". We could easily do this in the
admin one article at a time, but if we wanted to bulk-publish a group of
articles, it'd be tedious. So, let's write an action that lets us change an
article's status to "published."
Writing action functions
------------------------
First, we'll need to write a function that gets called when the action is
trigged from the admin. Action functions are just regular functions that take
two arguments: an :class:`~django.http.HttpRequest` representing the current
request, and a :class:`~django.db.models.QuerySet` containing the set of
objects selected by the user. Our publish-these-articles function won't need
the request object, but we will use the queryset::
def make_published(request, queryset):
queryset.update(status='p')
.. note::
For the best performance, we're using the queryset's :ref:`update method
<topics-db-queries-update>`. Other types of actions might need to deal
with each object individually; in these cases we'd just iterate over the
queryset::
for obj in queryset:
do_something_with(obj)
That's actually all there is to writing an action! However, we'll take one
more optional-but-useful step and give the action a "nice" title in the admin.
By default, this action would appear in the action list as "Make published" --
the function name, with underscores replaced by spaces. That's fine, but we
can provide a better, more human-friendly name by giving the
``make_published`` function a ``short_description`` attribute::
def make_published(request, queryset):
queryset.update(status='p')
make_published.short_description = "Mark selected stories as published"
.. note::
This might look familiar; the admin's ``list_display`` option uses the
same technique to provide human-readable descriptions for callback
functions registered there, too.
Adding actions to the :class:`ModelAdmin`
-----------------------------------------
Next, we'll need to inform our :class:`ModelAdmin` of the action. This works
just like any other configuration option. So, the complete ``admin.py`` with
the action and its registration would look like::
from django.contrib import admin
from myapp.models import Article
def make_published(request, queryset):
queryset.update(status='p')
make_published.short_description = "Mark selected stories as published"
class ArticleAdmin(admin.ModelAdmin):
list_display = ['title', 'status']
ordering = ['title']
actions = [make_published]
admin.site.register(Article, ArticleAdmin)
That code will give us an admin change list that looks something like this:
.. image:: _images/article_actions.png
That's really all there is to it! If you're itching to write your own actions,
you now know enough to get started. The rest of this document just covers more
advanced techniques.
Advanced action techniques
==========================
There's a couple of extra options and possibilities you can exploit for more
advanced options.
Actions as :class:`ModelAdmin` methods
--------------------------------------
The example above shows the ``make_published`` action defined as a simple
function. That's perfectly fine, but it's not perfect from a code design point
of view: since the action is tightly coupled to the ``Article`` object, it
makes sense to hook the action to the ``ArticleAdmin`` object itself.
That's easy enough to do::
class ArticleAdmin(admin.ModelAdmin):
...
actions = ['make_published']
def make_published(self, request, queryset):
queryset.update(status='p')
make_published.short_description = "Mark selected stories as published"
Notice first that we've moved ``make_published`` into a method (remembering to
add the ``self`` argument!), and second that we've now put the string
``'make_published'`` in ``actions`` instead of a direct function reference.
This tells the :class:`ModelAdmin` to look up the action as a method.
Defining actions as methods is especially nice because it gives the action
access to the :class:`ModelAdmin` itself, allowing the action to call any of
the methods provided by the admin.
For example, we can use ``self`` to flash a message to the user informing her
that the action was successful::
class ArticleAdmin(admin.ModelAdmin):
...
def make_published(self, request, queryset):
rows_updated = queryset.update(status='p')
if rows_updated == 1:
message_bit = "1 story was"
else:
message_bit = "%s stories were" % rows_updated
self.message_user(request, "%s successfully marked as published." % message_bit)
This make the action match what the admin itself does after successfully
performing an action:
.. image:: _images/article_actions_message.png
Actions that provide intermediate pages
---------------------------------------
By default, after an action is performed the user is simply redirected back
the the original change list page. However, some actions, especially more
complex ones, will need to return intermediate pages. For example, the
built-in delete action asks for confirmation before deleting the selected
objects.
To provide an intermediary page, simply return an
:class:`~django.http.HttpResponse` (or subclass) from your action. For
example, you might write a simple export function that uses Django's
:ref:`serialization functions <topics-serialization>` to dump some selected
objects as JSON::
from django.http import HttpResponse
from django.core import serializers
def export_as_json(request, queryset):
response = HttpResponse(mimetype="text/javascript")
serialize.serialize(queryset, stream=response)
return response
Generally, something like the above isn't considered a great idea. Most of the
time, the best practice will be to return an
:class:`~django.http.HttpResponseRedirect` and redirect the user to a view
you've written, passing the list of selected objects in the GET query string.
This allows you to provide complex interaction logic on the intermediary
pages. For example, if you wanted to provide a more complete export function,
you'd want to let the user choose a format, and possibly a list of fields to
include in the export. The best thing to do would be to write a small action that simply redirects
to your custom export view::
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
from django.http import HttpResponseRedirect
def export_selected_objects(request, queryset):
selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME)
ct = ContentType.objects.get_for_model(queryset.model)
return HttpResponseRedirect("/export/?ct=%s&ids=%s" % (ct.pk, ",".join(selected)))
As you can see, the action is the simple part; all the complex logic would
belong in your export view. This would need to deal with objects of any type,
hence the business with the ``ContentType``.
Writing this view is left as an exercise to the reader.
Making actions available globally
---------------------------------
Some actions are best if they're made available to *any* object in the admin
-- the export action defined above would be a good candidate. You can make an
action globally available using :meth:`AdminSite.add_action()`::
from django.contrib import admin
admin.site.add_action(export_selected_objects)

View File

@ -39,6 +39,14 @@ There are five steps in activating the Django admin site:
5. Hook the ``AdminSite`` instance into your URLconf.
Other topics
------------
.. toctree::
:maxdepth: 1
actions
``ModelAdmin`` objects
======================
@ -664,6 +672,19 @@ The value is another dictionary; these arguments will be passed to
that have ``raw_id_fields`` or ``radio_fields`` set. That's because
``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
``actions``
~~~~~~~~~~~
A list of actions to make available on the change list page. See
:ref:`ref-contrib-admin-actions` for details.
``actions_on_top``, ``actions_on_buttom``
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
Controls where on the page the actions bar appears. By default, the admin
changelist displays actions at the top of the page (``actions_on_top = True;
actions_on_bottom = False``).
``ModelAdmin`` methods
----------------------
@ -1138,7 +1159,6 @@ or add anything you like. Then, simply create an instance of your
Python class), and register your models and ``ModelAdmin`` subclasses
with it instead of using the default.
Hooking ``AdminSite`` instances into your URLconf
-------------------------------------------------
@ -1177,7 +1197,6 @@ There is really no need to use autodiscover when using your own ``AdminSite``
instance since you will likely be importing all the per-app admin.py modules
in your ``myproject.admin`` module.
Multiple admin sites in the same URLconf
----------------------------------------

View File

@ -24,7 +24,7 @@ those packages have.
.. toctree::
:maxdepth: 1
admin
admin/index
auth
comments/index
contenttypes

View File

@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
>>> site._registry[Person].search_fields
['name']
>>> site._registry[Person].list_display
['__str__']
['action_checkbox', '__str__']
>>> site._registry[Person].save_on_top
True

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<django-objects version="1.0">
<object pk="1" model="admin_views.subscriber">
<field type="CharField" name="name">John Doe</field>
<field type="CharField" name="email">john@example.org</field>
</object>
<object pk="2" model="admin_views.subscriber">
<field type="CharField" name="name">Max Mustermann</field>
<field type="CharField" name="email">max@example.org</field>
</object>
<object pk="1" model="admin_views.externalsubscriber">
<field type="CharField" name="name">John Doe</field>
<field type="CharField" name="email">john@example.org</field>
</object>
</django-objects>

View File

@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
from django.db import models
from django.contrib import admin
from django.core.mail import EmailMessage
class Section(models.Model):
"""
@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
BarAccountAdmin
)
class Subscriber(models.Model):
name = models.CharField(blank=False, max_length=80)
email = models.EmailField(blank=False, max_length=175)
def __unicode__(self):
return "%s (%s)" % (self.name, self.email)
class SubscriberAdmin(admin.ModelAdmin):
actions = ['delete_selected', 'mail_admin']
def mail_admin(self, request, selected):
EmailMessage(
'Greetings from a ModelAdmin action',
'This is the test email from a admin action',
'from@example.com',
['to@example.com']
).send()
class ExternalSubscriber(Subscriber):
pass
def external_mail(request, selected):
EmailMessage(
'Greetings from a function action',
'This is the test email from a function action',
'from@example.com',
['to@example.com']
).send()
def redirect_to(request, selected):
from django.http import HttpResponseRedirect
return HttpResponseRedirect('/some-where-else/')
class ExternalSubscriberAdmin(admin.ModelAdmin):
actions = [external_mail, redirect_to]
admin.site.register(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin)
@ -208,6 +244,8 @@ admin.site.register(Color)
admin.site.register(Thing, ThingAdmin)
admin.site.register(Person, PersonAdmin)
admin.site.register(Persona, PersonaAdmin)
admin.site.register(Subscriber, SubscriberAdmin)
admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
# That way we cover all four cases:

View File

@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
from django.contrib.admin.models import LogEntry
from django.contrib.admin.sites import LOGIN_FORM_KEY
from django.contrib.admin.util import quote
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
from django.utils.html import escape
# local test models
from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, ExternalSubscriber
try:
set
@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
def test_changelist_to_changeform_link(self):
"The link from the changelist referring to the changeform of the object should be quoted"
response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/')
should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
should_contain = """<th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
self.assertContains(response, should_contain)
def test_recentactions_link(self):
@ -743,12 +744,13 @@ class AdminViewListEditable(TestCase):
response = self.client.get('/test_admin/admin/admin_views/person/')
# 2 inputs per object(the field and the hidden id field) = 6
# 2 management hidden fields = 2
# 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
# main form submit button = 1
# search field and search submit button = 2
# 6 + 2 + 1 + 2 = 11 inputs
self.failUnlessEqual(response.content.count("<input"), 11)
self.failUnlessEqual(response.content.count("<input"), 15)
# 1 select per object = 3 selects
self.failUnlessEqual(response.content.count("<select"), 3)
self.failUnlessEqual(response.content.count("<select"), 4)
def test_post_submission(self):
data = {
@ -875,3 +877,65 @@ class AdminInheritedInlinesTest(TestCase):
self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
from django.core import mail
class AdminActionsTest(TestCase):
fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
def setUp(self):
self.client.login(username='super', password='secret')
def tearDown(self):
self.client.logout()
def test_model_admin_custom_action(self):
"Tests a custom action defined in a ModelAdmin method"
action_data = {
ACTION_CHECKBOX_NAME: [1],
'action' : 'mail_admin',
'index': 0,
}
response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
self.assertEquals(len(mail.outbox), 1)
self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
def test_model_admin_default_delete_action(self):
"Tests the default delete action defined as a ModelAdmin method"
action_data = {
ACTION_CHECKBOX_NAME: [1, 2],
'action' : 'delete_selected',
'index': 0,
}
delete_confirmation_data = {
ACTION_CHECKBOX_NAME: [1, 2],
'action' : 'delete_selected',
'index': 0,
'post': 'yes',
}
confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
self.failUnless(confirmation.content.count(ACTION_CHECKBOX_NAME) == 2)
response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
self.failUnlessEqual(Subscriber.objects.count(), 0)
def test_custom_function_mail_action(self):
"Tests a custom action defined in a function"
action_data = {
ACTION_CHECKBOX_NAME: [1],
'action' : 'external_mail',
'index': 0,
}
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
self.assertEquals(len(mail.outbox), 1)
self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
def test_custom_function_action_with_redirect(self):
"Tests a custom action defined in a function"
action_data = {
ACTION_CHECKBOX_NAME: [1],
'action' : 'redirect_to',
'index': 0,
}
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
self.failUnlessEqual(response.status_code, 302)