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
2
AUTHORS
@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
|
|||||||
Ned Batchelder <http://www.nedbatchelder.com/>
|
Ned Batchelder <http://www.nedbatchelder.com/>
|
||||||
batiste@dosimple.ch
|
batiste@dosimple.ch
|
||||||
Batman
|
Batman
|
||||||
|
Brian Beck <http://blog.brianbeck.com/>
|
||||||
Shannon -jj Behrens <http://jjinux.blogspot.com/>
|
Shannon -jj Behrens <http://jjinux.blogspot.com/>
|
||||||
Esdras Beleza <linux@esdrasbeleza.com>
|
Esdras Beleza <linux@esdrasbeleza.com>
|
||||||
Chris Bennett <chrisrbennett@yahoo.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>
|
Daniel Lindsley <polarcowz@gmail.com>
|
||||||
Trey Long <trey@ktrl.com>
|
Trey Long <trey@ktrl.com>
|
||||||
msaelices <msaelices@gmail.com>
|
msaelices <msaelices@gmail.com>
|
||||||
|
Martin Mahner <http://www.mahner.org/>
|
||||||
Matt McClanahan <http://mmcc.cx/>
|
Matt McClanahan <http://mmcc.cx/>
|
||||||
Frantisek Malina <vizualbod@vizualbod.com>
|
Frantisek Malina <vizualbod@vizualbod.com>
|
||||||
Martin Maney <http://www.chipy.org/Martin_Maney>
|
Martin Maney <http://www.chipy.org/Martin_Maney>
|
||||||
|
@ -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 ModelAdmin, HORIZONTAL, VERTICAL
|
||||||
from django.contrib.admin.options import StackedInline, TabularInline
|
from django.contrib.admin.options import StackedInline, TabularInline
|
||||||
from django.contrib.admin.sites import AdminSite, site
|
from django.contrib.admin.sites import AdminSite, site
|
||||||
|
@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
|
|||||||
from django.utils.encoding import force_unicode
|
from django.utils.encoding import force_unicode
|
||||||
from django.contrib.admin.util import flatten_fieldsets
|
from django.contrib.admin.util import flatten_fieldsets
|
||||||
from django.contrib.contenttypes.models import ContentType
|
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):
|
class AdminForm(object):
|
||||||
def __init__(self, form, fieldsets, prepopulated_fields):
|
def __init__(self, form, fieldsets, prepopulated_fields):
|
||||||
|
@ -50,12 +50,24 @@
|
|||||||
|
|
||||||
#changelist table thead th {
|
#changelist table thead th {
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
#changelist table thead th:first-child {
|
||||||
|
width: 1.5em;
|
||||||
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
#changelist table tbody td {
|
#changelist table tbody td {
|
||||||
border-left: 1px solid #ddd;
|
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 {
|
#changelist table tfoot {
|
||||||
color: #666;
|
color: #666;
|
||||||
}
|
}
|
||||||
@ -209,3 +221,35 @@
|
|||||||
border-color: #036;
|
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;
|
||||||
|
}
|
||||||
|
19
django/contrib/admin/media/js/actions.js
Normal 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);
|
@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
|
|||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
from django.contrib.admin import widgets
|
from django.contrib.admin import widgets
|
||||||
from django.contrib.admin import helpers
|
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.core.exceptions import PermissionDenied
|
||||||
from django.db import models, transaction
|
from django.db import models, transaction
|
||||||
|
from django.db.models.fields import BLANK_CHOICE_DASH
|
||||||
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
from django.http import Http404, HttpResponse, HttpResponseRedirect
|
||||||
from django.shortcuts import get_object_or_404, render_to_response
|
from django.shortcuts import get_object_or_404, render_to_response
|
||||||
from django.utils.functional import update_wrapper
|
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.functional import curry
|
||||||
from django.utils.text import capfirst, get_text_list
|
from django.utils.text import capfirst, get_text_list
|
||||||
from django.utils.translation import ugettext as _
|
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
|
from django.utils.encoding import force_unicode
|
||||||
try:
|
try:
|
||||||
set
|
set
|
||||||
@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
delete_confirmation_template = None
|
delete_confirmation_template = None
|
||||||
object_history_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):
|
def __init__(self, model, admin_site):
|
||||||
self.model = model
|
self.model = model
|
||||||
self.opts = model._meta
|
self.opts = model._meta
|
||||||
@ -200,6 +207,13 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
for inline_class in self.inlines:
|
for inline_class in self.inlines:
|
||||||
inline_instance = inline_class(self.model, self.admin_site)
|
inline_instance = inline_class(self.model, self.admin_site)
|
||||||
self.inline_instances.append(inline_instance)
|
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__()
|
super(ModelAdmin, self).__init__()
|
||||||
|
|
||||||
def get_urls(self):
|
def get_urls(self):
|
||||||
@ -239,6 +253,8 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
|
||||||
js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
|
js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
|
||||||
|
if self.actions:
|
||||||
|
js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
|
||||||
if self.prepopulated_fields:
|
if self.prepopulated_fields:
|
||||||
js.append('js/urlify.js')
|
js.append('js/urlify.js')
|
||||||
if self.opts.get_ordered_objects():
|
if self.opts.get_ordered_objects():
|
||||||
@ -390,6 +406,121 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
action_flag = DELETION
|
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):
|
def construct_change_message(self, request, form, formsets):
|
||||||
"""
|
"""
|
||||||
@ -529,6 +660,48 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
self.message_user(request, msg)
|
self.message_user(request, msg)
|
||||||
return HttpResponseRedirect("../")
|
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):
|
def add_view(self, request, form_url='', extra_context=None):
|
||||||
"The 'add' admin view for this model."
|
"The 'add' admin view for this model."
|
||||||
model = self.model
|
model = self.model
|
||||||
@ -721,6 +894,14 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
|
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
|
||||||
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
|
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
|
# 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
|
# for the changelist given all the fields to be edited. Then we'll
|
||||||
# use the formset to validate/process POSTed data.
|
# use the formset to validate/process POSTed data.
|
||||||
@ -764,7 +945,11 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
if formset:
|
if formset:
|
||||||
media = self.media + formset.media
|
media = self.media + formset.media
|
||||||
else:
|
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 = {
|
context = {
|
||||||
'title': cl.title,
|
'title': cl.title,
|
||||||
@ -774,6 +959,9 @@ class ModelAdmin(BaseModelAdmin):
|
|||||||
'has_add_permission': self.has_add_permission(request),
|
'has_add_permission': self.has_add_permission(request),
|
||||||
'root_path': self.admin_site.root_path,
|
'root_path': self.admin_site.root_path,
|
||||||
'app_label': app_label,
|
'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 {})
|
context.update(extra_context or {})
|
||||||
return render_to_response(self.change_list_template or [
|
return render_to_response(self.change_list_template or [
|
||||||
|
@ -45,6 +45,8 @@ class AdminSite(object):
|
|||||||
name += '_'
|
name += '_'
|
||||||
self.name = name
|
self.name = name
|
||||||
|
|
||||||
|
self.actions = []
|
||||||
|
|
||||||
def register(self, model_or_iterable, admin_class=None, **options):
|
def register(self, model_or_iterable, admin_class=None, **options):
|
||||||
"""
|
"""
|
||||||
Registers the given model(s) with the given admin class.
|
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__)
|
raise NotRegistered('The model %s is not registered' % model.__name__)
|
||||||
del self._registry[model]
|
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):
|
def has_permission(self, request):
|
||||||
"""
|
"""
|
||||||
Returns True if the given HttpRequest has permission to view
|
Returns True if the given HttpRequest has permission to view
|
||||||
|
5
django/contrib/admin/templates/admin/actions.html
Normal 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>
|
@ -7,8 +7,8 @@
|
|||||||
{% if cl.formset %}
|
{% if cl.formset %}
|
||||||
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
|
<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
|
||||||
<script type="text/javascript" src="../../jsi18n/"></script>
|
<script type="text/javascript" src="../../jsi18n/"></script>
|
||||||
{{ media }}
|
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
{{ media }}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bodyclass %}change-list{% endblock %}
|
{% block bodyclass %}change-list{% endblock %}
|
||||||
@ -63,14 +63,18 @@
|
|||||||
{% endif %}
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
|
<form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
|
||||||
{% if cl.formset %}
|
{% if cl.formset %}
|
||||||
<form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
|
|
||||||
{{ cl.formset.management_form }}
|
{{ cl.formset.management_form }}
|
||||||
{% endif %}
|
{% 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 %}
|
{% block pagination %}{% pagination cl %}{% endblock %}
|
||||||
{% if cl.formset %}</form>{% endif %}
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
@ -0,0 +1,37 @@
|
|||||||
|
{% extends "admin/base_site.html" %}
|
||||||
|
{% load i18n %}
|
||||||
|
|
||||||
|
{% block breadcrumbs %}
|
||||||
|
<div class="breadcrumbs">
|
||||||
|
<a href="../../">{% trans "Home" %}</a> ›
|
||||||
|
<a href="../">{{ app_label|capfirst }}</a> ›
|
||||||
|
<a href="./">{{ opts.verbose_name_plural|capfirst }}</a> ›
|
||||||
|
{% 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 %}
|
@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
|
|||||||
def admin_list_filter(cl, spec):
|
def admin_list_filter(cl, spec):
|
||||||
return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
|
return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
|
||||||
admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
|
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)
|
||||||
|
@ -4,7 +4,8 @@ from django.utils.html import escape
|
|||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
from django.utils.text import capfirst
|
from django.utils.text import capfirst
|
||||||
from django.utils.encoding import force_unicode
|
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):
|
def quote(s):
|
||||||
"""
|
"""
|
||||||
@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
|
|||||||
current = current[-1]
|
current = current[-1]
|
||||||
current.append(val)
|
current.append(val)
|
||||||
|
|
||||||
def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
|
def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
|
||||||
"Helper function that recursively populates deleted_objects."
|
"""
|
||||||
|
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
|
nh = _nest_help # Bind to local variable for performance
|
||||||
if current_depth > 16:
|
if current_depth > 16:
|
||||||
return # Avoid recursing too deep.
|
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)), []])
|
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||||
else:
|
else:
|
||||||
# Display a link to the admin page.
|
# 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)),
|
(escape(capfirst(related.opts.verbose_name)),
|
||||||
related.opts.app_label,
|
get_change_view_url(related.opts.app_label,
|
||||||
related.opts.object_name.lower(),
|
related.opts.object_name.lower(),
|
||||||
sub_obj._get_pk_val(),
|
sub_obj._get_pk_val(),
|
||||||
|
admin_site,
|
||||||
|
levels_to_root),
|
||||||
escape(sub_obj))), []])
|
escape(sub_obj))), []])
|
||||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
||||||
else:
|
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)), []])
|
[u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
|
||||||
else:
|
else:
|
||||||
# Display a link to the admin page.
|
# 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)),
|
(escape(capfirst(related.opts.verbose_name)),
|
||||||
related.opts.app_label,
|
get_change_view_url(related.opts.app_label,
|
||||||
related.opts.object_name.lower(),
|
related.opts.object_name.lower(),
|
||||||
sub_obj._get_pk_val(),
|
sub_obj._get_pk_val(),
|
||||||
|
admin_site,
|
||||||
|
levels_to_root),
|
||||||
escape(sub_obj))), []])
|
escape(sub_obj))), []])
|
||||||
get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
|
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
|
# 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.
|
# Display a link to the admin page.
|
||||||
nh(deleted_objects, current_depth, [
|
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))}) + \
|
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>' % \
|
(u' <a href="%s">%s</a>' % \
|
||||||
(related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
|
(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
|
# If there were related objects, and the user doesn't have
|
||||||
# permission to change them, add the missing perm to perms_needed.
|
# permission to change them, add the missing perm to perms_needed.
|
||||||
if has_admin and has_related_objs:
|
if has_admin and has_related_objs:
|
||||||
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
|
p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
|
||||||
if not user.has_perm(p):
|
if not user.has_perm(p):
|
||||||
perms_needed.add(related.opts.verbose_name)
|
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)
|
||||||
|
@ -127,6 +127,14 @@ def validate(cls, model):
|
|||||||
continue
|
continue
|
||||||
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
|
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
|
# list_select_related = False
|
||||||
# save_as = False
|
# save_as = False
|
||||||
# save_on_top = False
|
# save_on_top = False
|
||||||
@ -135,6 +143,7 @@ def validate(cls, model):
|
|||||||
raise ImproperlyConfigured("'%s.%s' should be a boolean."
|
raise ImproperlyConfigured("'%s.%s' should be a boolean."
|
||||||
% (cls.__name__, attr))
|
% (cls.__name__, attr))
|
||||||
|
|
||||||
|
|
||||||
# inlines = []
|
# inlines = []
|
||||||
if hasattr(cls, 'inlines'):
|
if hasattr(cls, 'inlines'):
|
||||||
check_isseq(cls, 'inlines', cls.inlines)
|
check_isseq(cls, 'inlines', cls.inlines)
|
||||||
|
@ -78,7 +78,7 @@ The development process
|
|||||||
Other batteries included
|
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:`Authentication <topics-auth>`
|
||||||
* :ref:`Cache system <topics-cache>`
|
* :ref:`Cache system <topics-cache>`
|
||||||
* :ref:`Conditional content processing <topics-conditional-processing>`
|
* :ref:`Conditional content processing <topics-conditional-processing>`
|
||||||
|
BIN
docs/ref/contrib/admin/_images/article_actions.png
Normal file
After Width: | Height: | Size: 35 KiB |
BIN
docs/ref/contrib/admin/_images/article_actions_message.png
Normal file
After Width: | Height: | Size: 21 KiB |
Before Width: | Height: | Size: 41 KiB After Width: | Height: | Size: 41 KiB |
BIN
docs/ref/contrib/admin/_images/user_actions.png
Normal file
After Width: | Height: | Size: 25 KiB |
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 58 KiB |
239
docs/ref/contrib/admin/actions.txt
Normal 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)
|
||||||
|
|
@ -39,6 +39,14 @@ There are five steps in activating the Django admin site:
|
|||||||
|
|
||||||
5. Hook the ``AdminSite`` instance into your URLconf.
|
5. Hook the ``AdminSite`` instance into your URLconf.
|
||||||
|
|
||||||
|
Other topics
|
||||||
|
------------
|
||||||
|
|
||||||
|
.. toctree::
|
||||||
|
:maxdepth: 1
|
||||||
|
|
||||||
|
actions
|
||||||
|
|
||||||
``ModelAdmin`` objects
|
``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
|
that have ``raw_id_fields`` or ``radio_fields`` set. That's because
|
||||||
``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
|
``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
|
``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
|
Python class), and register your models and ``ModelAdmin`` subclasses
|
||||||
with it instead of using the default.
|
with it instead of using the default.
|
||||||
|
|
||||||
|
|
||||||
Hooking ``AdminSite`` instances into your URLconf
|
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
|
instance since you will likely be importing all the per-app admin.py modules
|
||||||
in your ``myproject.admin`` module.
|
in your ``myproject.admin`` module.
|
||||||
|
|
||||||
|
|
||||||
Multiple admin sites in the same URLconf
|
Multiple admin sites in the same URLconf
|
||||||
----------------------------------------
|
----------------------------------------
|
||||||
|
|
@ -24,7 +24,7 @@ those packages have.
|
|||||||
.. toctree::
|
.. toctree::
|
||||||
:maxdepth: 1
|
:maxdepth: 1
|
||||||
|
|
||||||
admin
|
admin/index
|
||||||
auth
|
auth
|
||||||
comments/index
|
comments/index
|
||||||
contenttypes
|
contenttypes
|
||||||
|
@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
|
|||||||
>>> site._registry[Person].search_fields
|
>>> site._registry[Person].search_fields
|
||||||
['name']
|
['name']
|
||||||
>>> site._registry[Person].list_display
|
>>> site._registry[Person].list_display
|
||||||
['__str__']
|
['action_checkbox', '__str__']
|
||||||
>>> site._registry[Person].save_on_top
|
>>> site._registry[Person].save_on_top
|
||||||
True
|
True
|
||||||
|
|
||||||
|
@ -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>
|
@ -1,6 +1,7 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from django.db import models
|
from django.db import models
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
|
from django.core.mail import EmailMessage
|
||||||
|
|
||||||
class Section(models.Model):
|
class Section(models.Model):
|
||||||
"""
|
"""
|
||||||
@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
|
|||||||
BarAccountAdmin
|
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(Article, ArticleAdmin)
|
||||||
admin.site.register(CustomArticle, CustomArticleAdmin)
|
admin.site.register(CustomArticle, CustomArticleAdmin)
|
||||||
@ -208,6 +244,8 @@ admin.site.register(Color)
|
|||||||
admin.site.register(Thing, ThingAdmin)
|
admin.site.register(Thing, ThingAdmin)
|
||||||
admin.site.register(Person, PersonAdmin)
|
admin.site.register(Person, PersonAdmin)
|
||||||
admin.site.register(Persona, PersonaAdmin)
|
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.
|
# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
|
||||||
# That way we cover all four cases:
|
# That way we cover all four cases:
|
||||||
|
@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
|
|||||||
from django.contrib.admin.models import LogEntry
|
from django.contrib.admin.models import LogEntry
|
||||||
from django.contrib.admin.sites import LOGIN_FORM_KEY
|
from django.contrib.admin.sites import LOGIN_FORM_KEY
|
||||||
from django.contrib.admin.util import quote
|
from django.contrib.admin.util import quote
|
||||||
|
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
|
|
||||||
# local test models
|
# 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:
|
try:
|
||||||
set
|
set
|
||||||
@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
|
|||||||
def test_changelist_to_changeform_link(self):
|
def test_changelist_to_changeform_link(self):
|
||||||
"The link from the changelist referring to the changeform of the object should be quoted"
|
"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/')
|
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)
|
self.assertContains(response, should_contain)
|
||||||
|
|
||||||
def test_recentactions_link(self):
|
def test_recentactions_link(self):
|
||||||
@ -743,12 +744,13 @@ class AdminViewListEditable(TestCase):
|
|||||||
response = self.client.get('/test_admin/admin/admin_views/person/')
|
response = self.client.get('/test_admin/admin/admin_views/person/')
|
||||||
# 2 inputs per object(the field and the hidden id field) = 6
|
# 2 inputs per object(the field and the hidden id field) = 6
|
||||||
# 2 management hidden fields = 2
|
# 2 management hidden fields = 2
|
||||||
|
# 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
|
||||||
# main form submit button = 1
|
# main form submit button = 1
|
||||||
# search field and search submit button = 2
|
# search field and search submit button = 2
|
||||||
# 6 + 2 + 1 + 2 = 11 inputs
|
# 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
|
# 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):
|
def test_post_submission(self):
|
||||||
data = {
|
data = {
|
||||||
@ -875,3 +877,65 @@ class AdminInheritedInlinesTest(TestCase):
|
|||||||
self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
|
self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
|
||||||
self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
|
self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
|
||||||
self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
|
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)
|
||||||
|