Made a bunch of improvements to admin actions. Be warned: this includes one minor but BACKWARDS-INCOMPATIBLE change.
These changes are: * BACKWARDS-INCOMPATIBLE CHANGE: action functions and action methods now share the same signature: `(modeladmin, request, queryset)`. Actions defined as methods stay the same, but if you've defined an action as a standalone function you'll now need to add that first `modeladmin` argument. * The delete selected action is now a standalone function registered site-wide; this makes disabling it easy. * Fixed #10596: there are now official, documented `AdminSite` APIs for dealing with actions, including a method to disable global actions. You can still re-enable globally-disabled actions on a case-by-case basis. * Fixed #10595: you can now disable actions for a particular `ModelAdmin` by setting `actions` to `None`. * Fixed #10734: actions are now sorted (by name). * Fixed #10618: the action is now taken from the form whose "submit" button you clicked, not arbitrarily the last form on the page. * All of the above is documented and tested. git-svn-id: http://code.djangoproject.com/svn/django/trunk@10408 bcc190cf-cafb-0310-a4f2-bffc1f526a37
This commit is contained in:
parent
d0c897d660
commit
bb15cee58a
|
@ -0,0 +1,81 @@
|
||||||
|
"""
|
||||||
|
Built-in, globally-available admin actions.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django import template
|
||||||
|
from django.core.exceptions import PermissionDenied
|
||||||
|
from django.contrib.admin import helpers
|
||||||
|
from django.contrib.admin.util import get_deleted_objects, model_ngettext
|
||||||
|
from django.shortcuts import render_to_response
|
||||||
|
from django.utils.encoding import force_unicode
|
||||||
|
from django.utils.html import escape
|
||||||
|
from django.utils.safestring import mark_safe
|
||||||
|
from django.utils.text import capfirst
|
||||||
|
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||||
|
|
||||||
|
def delete_selected(modeladmin, request, queryset):
|
||||||
|
"""
|
||||||
|
Default action which deletes the selected objects.
|
||||||
|
|
||||||
|
This action first displays a confirmation page whichs shows all the
|
||||||
|
deleteable objects, or, if the user has no permission one of the related
|
||||||
|
childs (foreignkeys), a "permission denied" message.
|
||||||
|
|
||||||
|
Next, it delets all selected objects and redirects back to the change list.
|
||||||
|
"""
|
||||||
|
opts = modeladmin.model._meta
|
||||||
|
app_label = opts.app_label
|
||||||
|
|
||||||
|
# Check that the user has delete permission for the actual model
|
||||||
|
if not modeladmin.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, modeladmin.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)
|
||||||
|
modeladmin.log_deletion(request, obj, obj_display)
|
||||||
|
queryset.delete()
|
||||||
|
modeladmin.message_user(request, _("Successfully deleted %(count)d %(items)s.") % {
|
||||||
|
"count": n, "items": model_ngettext(modeladmin.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": modeladmin.admin_site.root_path,
|
||||||
|
"app_label": app_label,
|
||||||
|
'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Display the confirmation page
|
||||||
|
return render_to_response(modeladmin.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")
|
|
@ -11,6 +11,7 @@ from django.db import models, transaction
|
||||||
from django.db.models.fields import BLANK_CHOICE_DASH
|
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.datastructures import SortedDict
|
||||||
from django.utils.functional import update_wrapper
|
from django.utils.functional import update_wrapper
|
||||||
from django.utils.html import escape
|
from django.utils.html import escape
|
||||||
from django.utils.safestring import mark_safe
|
from django.utils.safestring import mark_safe
|
||||||
|
@ -194,7 +195,7 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
object_history_template = None
|
object_history_template = None
|
||||||
|
|
||||||
# Actions
|
# Actions
|
||||||
actions = ['delete_selected']
|
actions = []
|
||||||
action_form = helpers.ActionForm
|
action_form = helpers.ActionForm
|
||||||
actions_on_top = True
|
actions_on_top = True
|
||||||
actions_on_bottom = False
|
actions_on_bottom = False
|
||||||
|
@ -207,7 +208,7 @@ 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:
|
if 'action_checkbox' not in self.list_display and self.actions is not None:
|
||||||
self.list_display = ['action_checkbox'] + list(self.list_display)
|
self.list_display = ['action_checkbox'] + list(self.list_display)
|
||||||
if not self.list_display_links:
|
if not self.list_display_links:
|
||||||
for name in self.list_display:
|
for name in self.list_display:
|
||||||
|
@ -253,7 +254,7 @@ 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:
|
if self.actions is not None:
|
||||||
js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
|
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')
|
||||||
|
@ -414,19 +415,46 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
|
action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
|
||||||
action_checkbox.allow_tags = True
|
action_checkbox.allow_tags = True
|
||||||
|
|
||||||
def get_actions(self, request=None):
|
def get_actions(self, request):
|
||||||
"""
|
"""
|
||||||
Return a dictionary mapping the names of all actions for this
|
Return a dictionary mapping the names of all actions for this
|
||||||
ModelAdmin to a tuple of (callable, name, description) for each action.
|
ModelAdmin to a tuple of (callable, name, description) for each action.
|
||||||
"""
|
"""
|
||||||
actions = {}
|
# If self.actions is explicitally set to None that means that we don't
|
||||||
for klass in [self.admin_site] + self.__class__.mro()[::-1]:
|
# want *any* actions enabled on this page.
|
||||||
for action in getattr(klass, 'actions', []):
|
if self.actions is None:
|
||||||
func, name, description = self.get_action(action)
|
return []
|
||||||
actions[name] = (func, name, description)
|
|
||||||
|
actions = []
|
||||||
|
|
||||||
|
# Gather actions from the admin site first
|
||||||
|
for (name, func) in self.admin_site.actions:
|
||||||
|
description = getattr(func, 'short_description', name.replace('_', ' '))
|
||||||
|
actions.append((func, name, description))
|
||||||
|
|
||||||
|
# Then gather them from the model admin and all parent classes,
|
||||||
|
# starting with self and working back up.
|
||||||
|
for klass in self.__class__.mro()[::-1]:
|
||||||
|
class_actions = getattr(klass, 'actions', [])
|
||||||
|
# Avoid trying to iterate over None
|
||||||
|
if not class_actions:
|
||||||
|
continue
|
||||||
|
actions.extend([self.get_action(action) for action in class_actions])
|
||||||
|
|
||||||
|
# get_action might have returned None, so filter any of those out.
|
||||||
|
actions = filter(None, actions)
|
||||||
|
|
||||||
|
# Convert the actions into a SortedDict keyed by name
|
||||||
|
# and sorted by description.
|
||||||
|
actions.sort(lambda a,b: cmp(a[2].lower(), b[2].lower()))
|
||||||
|
actions = SortedDict([
|
||||||
|
(name, (func, name, desc))
|
||||||
|
for func, name, desc in actions
|
||||||
|
])
|
||||||
|
|
||||||
return actions
|
return actions
|
||||||
|
|
||||||
def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
|
def get_action_choices(self, request, default_choices=BLANK_CHOICE_DASH):
|
||||||
"""
|
"""
|
||||||
Return a list of choices for use in a form object. Each choice is a
|
Return a list of choices for use in a form object. Each choice is a
|
||||||
tuple (name, description).
|
tuple (name, description).
|
||||||
|
@ -443,85 +471,30 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
or the name of a method on the ModelAdmin. Return is a tuple of
|
or the name of a method on the ModelAdmin. Return is a tuple of
|
||||||
(callable, name, description).
|
(callable, name, description).
|
||||||
"""
|
"""
|
||||||
|
# If the action is a callable, just use it.
|
||||||
if callable(action):
|
if callable(action):
|
||||||
func = action
|
func = action
|
||||||
action = action.__name__
|
action = action.__name__
|
||||||
elif hasattr(self, action):
|
|
||||||
func = getattr(self, action)
|
# Next, look for a method. Grab it off self.__class__ to get an unbound
|
||||||
|
# method instead of a bound one; this ensures that the calling
|
||||||
|
# conventions are the same for functions and methods.
|
||||||
|
elif hasattr(self.__class__, action):
|
||||||
|
func = getattr(self.__class__, action)
|
||||||
|
|
||||||
|
# Finally, look for a named method on the admin site
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
func = self.admin_site.get_action(action)
|
||||||
|
except KeyError:
|
||||||
|
return None
|
||||||
|
|
||||||
if hasattr(func, 'short_description'):
|
if hasattr(func, 'short_description'):
|
||||||
description = func.short_description
|
description = func.short_description
|
||||||
else:
|
else:
|
||||||
description = capfirst(action.replace('_', ' '))
|
description = capfirst(action.replace('_', ' '))
|
||||||
return func, action, description
|
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 %(count)d %(items)s.") % {
|
|
||||||
"count": n, "items": 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):
|
||||||
"""
|
"""
|
||||||
Construct a change message from a changed object.
|
Construct a change message from a changed object.
|
||||||
|
@ -678,6 +651,16 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
data = request.POST.copy()
|
data = request.POST.copy()
|
||||||
data.pop(helpers.ACTION_CHECKBOX_NAME, None)
|
data.pop(helpers.ACTION_CHECKBOX_NAME, None)
|
||||||
data.pop("index", None)
|
data.pop("index", None)
|
||||||
|
|
||||||
|
# Use the action whose button was pushed
|
||||||
|
try:
|
||||||
|
data.update({'action': data.getlist('action')[action_index]})
|
||||||
|
except IndexError:
|
||||||
|
# If we didn't get an action from the chosen form that's invalid
|
||||||
|
# POST data, so by deleting action it'll fail the validation check
|
||||||
|
# below. So no need to do anything here
|
||||||
|
pass
|
||||||
|
|
||||||
action_form = self.action_form(data, auto_id=None)
|
action_form = self.action_form(data, auto_id=None)
|
||||||
action_form.fields['action'].choices = self.get_action_choices(request)
|
action_form.fields['action'].choices = self.get_action_choices(request)
|
||||||
|
|
||||||
|
@ -692,7 +675,7 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
if not selected:
|
if not selected:
|
||||||
return None
|
return None
|
||||||
|
|
||||||
response = func(request, queryset.filter(pk__in=selected))
|
response = func(self, request, queryset.filter(pk__in=selected))
|
||||||
|
|
||||||
# Actions may return an HttpResponse, which will be used as the
|
# Actions may return an HttpResponse, which will be used as the
|
||||||
# response from the POST. If not, we'll be a good little HTTP
|
# response from the POST. If not, we'll be a good little HTTP
|
||||||
|
@ -881,8 +864,20 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
app_label = opts.app_label
|
app_label = opts.app_label
|
||||||
if not self.has_change_permission(request, None):
|
if not self.has_change_permission(request, None):
|
||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
|
# Check actions to see if any are available on this changelist
|
||||||
|
actions = self.get_actions(request)
|
||||||
|
|
||||||
|
# Remove action checkboxes if there aren't any actions available.
|
||||||
|
list_display = list(self.list_display)
|
||||||
|
if not actions:
|
||||||
|
try:
|
||||||
|
list_display.remove('action_checkbox')
|
||||||
|
except ValueError:
|
||||||
|
pass
|
||||||
|
|
||||||
try:
|
try:
|
||||||
cl = ChangeList(request, self.model, self.list_display, self.list_display_links, self.list_filter,
|
cl = ChangeList(request, self.model, list_display, self.list_display_links, self.list_filter,
|
||||||
self.date_hierarchy, self.search_fields, self.list_select_related, self.list_per_page, self.list_editable, self)
|
self.date_hierarchy, self.search_fields, self.list_select_related, self.list_per_page, self.list_editable, self)
|
||||||
except IncorrectLookupParameters:
|
except IncorrectLookupParameters:
|
||||||
# Wacky lookup parameters were given, so redirect to the main
|
# Wacky lookup parameters were given, so redirect to the main
|
||||||
|
@ -893,11 +888,11 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
if ERROR_FLAG in request.GET.keys():
|
if ERROR_FLAG in request.GET.keys():
|
||||||
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.
|
# 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
|
# 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.
|
# will fall through to the bulk edit check, below.
|
||||||
if request.method == 'POST':
|
if actions and request.method == 'POST':
|
||||||
response = self.response_action(request, queryset=cl.get_query_set())
|
response = self.response_action(request, queryset=cl.get_query_set())
|
||||||
if response:
|
if response:
|
||||||
return response
|
return response
|
||||||
|
@ -948,8 +943,11 @@ class ModelAdmin(BaseModelAdmin):
|
||||||
media = self.media
|
media = self.media
|
||||||
|
|
||||||
# Build the action form and populate it with available actions.
|
# Build the action form and populate it with available actions.
|
||||||
action_form = self.action_form(auto_id=None)
|
if actions:
|
||||||
action_form.fields['action'].choices = self.get_action_choices(request)
|
action_form = self.action_form(auto_id=None)
|
||||||
|
action_form.fields['action'].choices = self.get_action_choices(request)
|
||||||
|
else:
|
||||||
|
action_form = None
|
||||||
|
|
||||||
context = {
|
context = {
|
||||||
'title': cl.title,
|
'title': cl.title,
|
||||||
|
|
|
@ -1,6 +1,7 @@
|
||||||
import re
|
import re
|
||||||
from django import http, template
|
from django import http, template
|
||||||
from django.contrib.admin import ModelAdmin
|
from django.contrib.admin import ModelAdmin
|
||||||
|
from django.contrib.admin import actions
|
||||||
from django.contrib.auth import authenticate, login
|
from django.contrib.auth import authenticate, login
|
||||||
from django.db.models.base import ModelBase
|
from django.db.models.base import ModelBase
|
||||||
from django.core.exceptions import ImproperlyConfigured
|
from django.core.exceptions import ImproperlyConfigured
|
||||||
|
@ -11,6 +12,10 @@ from django.utils.text import capfirst
|
||||||
from django.utils.translation import ugettext_lazy, ugettext as _
|
from django.utils.translation import ugettext_lazy, ugettext as _
|
||||||
from django.views.decorators.cache import never_cache
|
from django.views.decorators.cache import never_cache
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
try:
|
||||||
|
set
|
||||||
|
except NameError:
|
||||||
|
from sets import Set as set # Python 2.3 fallback
|
||||||
|
|
||||||
ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.")
|
ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.")
|
||||||
LOGIN_FORM_KEY = 'this_is_the_login_form'
|
LOGIN_FORM_KEY = 'this_is_the_login_form'
|
||||||
|
@ -44,8 +49,8 @@ class AdminSite(object):
|
||||||
else:
|
else:
|
||||||
name += '_'
|
name += '_'
|
||||||
self.name = name
|
self.name = name
|
||||||
|
self._actions = {'delete_selected': actions.delete_selected}
|
||||||
self.actions = []
|
self._global_actions = self._actions.copy()
|
||||||
|
|
||||||
def register(self, model_or_iterable, admin_class=None, **options):
|
def register(self, model_or_iterable, admin_class=None, **options):
|
||||||
"""
|
"""
|
||||||
|
@ -102,10 +107,33 @@ 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):
|
def add_action(self, action, name=None):
|
||||||
if not callable(action):
|
"""
|
||||||
raise TypeError("You can only register callable actions through an admin site")
|
Register an action to be available globally.
|
||||||
self.actions.append(action)
|
"""
|
||||||
|
name = name or action.__name__
|
||||||
|
self._actions[name] = action
|
||||||
|
self._global_actions[name] = action
|
||||||
|
|
||||||
|
def disable_action(self, name):
|
||||||
|
"""
|
||||||
|
Disable a globally-registered action. Raises KeyError for invalid names.
|
||||||
|
"""
|
||||||
|
del self._actions[name]
|
||||||
|
|
||||||
|
def get_action(self, name):
|
||||||
|
"""
|
||||||
|
Explicitally get a registered global action wheather it's enabled or
|
||||||
|
not. Raises KeyError for invalid names.
|
||||||
|
"""
|
||||||
|
return self._global_actions[name]
|
||||||
|
|
||||||
|
def actions(self):
|
||||||
|
"""
|
||||||
|
Get all the enabled actions as an iterable of (name, func).
|
||||||
|
"""
|
||||||
|
return self._actions.iteritems()
|
||||||
|
actions = property(actions)
|
||||||
|
|
||||||
def has_permission(self, request):
|
def has_permission(self, request):
|
||||||
"""
|
"""
|
||||||
|
|
|
@ -9,6 +9,11 @@
|
||||||
<script type="text/javascript" src="../../jsi18n/"></script>
|
<script type="text/javascript" src="../../jsi18n/"></script>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ media }}
|
{{ media }}
|
||||||
|
{% if not actions_on_top and not actions_on_bottom %}
|
||||||
|
<style>
|
||||||
|
#changelist table thead th:first-child {width: inherit}
|
||||||
|
</style>
|
||||||
|
{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|
||||||
{% block bodyclass %}change-list{% endblock %}
|
{% block bodyclass %}change-list{% endblock %}
|
||||||
|
@ -69,9 +74,9 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
|
|
||||||
{% block result_list %}
|
{% block result_list %}
|
||||||
{% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
|
{% if action_form and actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
|
||||||
{% result_list cl %}
|
{% result_list cl %}
|
||||||
{% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
|
{% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
{% block pagination %}{% pagination cl %}{% endblock %}
|
{% block pagination %}{% pagination cl %}{% endblock %}
|
||||||
</form>
|
</form>
|
||||||
|
|
|
@ -8,5 +8,5 @@
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{{ cl.result_count }} {% ifequal cl.result_count 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endifequal %}
|
{{ cl.result_count }} {% ifequal cl.result_count 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endifequal %}
|
||||||
{% if show_all_url %} <a href="{{ show_all_url }}" class="showall">{% trans 'Show all' %}</a>{% endif %}
|
{% if show_all_url %} <a href="{{ show_all_url }}" class="showall">{% trans 'Show all' %}</a>{% endif %}
|
||||||
{% if cl.formset %}<input type="submit" name="_save" class="default" value="Save"/>{% endif %}
|
{% if cl.formset and cl.result_count %}<input type="submit" name="_save" class="default" value="Save"/>{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
|
|
@ -127,14 +127,6 @@ 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
|
||||||
|
|
|
@ -31,8 +31,8 @@ Writing actions
|
||||||
|
|
||||||
The easiest way to explain actions is by example, so let's dive in.
|
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
|
A common use case for admin actions is the bulk updating of a model. Imagine a
|
||||||
news application with an ``Article`` model::
|
simple news application with an ``Article`` model::
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
|
@ -61,12 +61,17 @@ Writing action functions
|
||||||
|
|
||||||
First, we'll need to write a function that gets called when the action is
|
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
|
trigged from the admin. Action functions are just regular functions that take
|
||||||
two arguments: an :class:`~django.http.HttpRequest` representing the current
|
three arguments:
|
||||||
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 current :class:`ModelAdmin`
|
||||||
the request object, but we will use the queryset::
|
* An :class:`~django.http.HttpRequest` representing the current request,
|
||||||
|
* A :class:`~django.db.models.QuerySet` containing the set of objects
|
||||||
|
selected by the user.
|
||||||
|
|
||||||
def make_published(request, queryset):
|
Our publish-these-articles function won't need the :class:`ModelAdmin` or the
|
||||||
|
request object, but we will use the queryset::
|
||||||
|
|
||||||
|
def make_published(modeladmin, request, queryset):
|
||||||
queryset.update(status='p')
|
queryset.update(status='p')
|
||||||
|
|
||||||
.. note::
|
.. note::
|
||||||
|
@ -86,7 +91,7 @@ the function name, with underscores replaced by spaces. That's fine, but we
|
||||||
can provide a better, more human-friendly name by giving the
|
can provide a better, more human-friendly name by giving the
|
||||||
``make_published`` function a ``short_description`` attribute::
|
``make_published`` function a ``short_description`` attribute::
|
||||||
|
|
||||||
def make_published(request, queryset):
|
def make_published(modeladmin, request, queryset):
|
||||||
queryset.update(status='p')
|
queryset.update(status='p')
|
||||||
make_published.short_description = "Mark selected stories as published"
|
make_published.short_description = "Mark selected stories as published"
|
||||||
|
|
||||||
|
@ -106,7 +111,7 @@ the action and its registration would look like::
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from myapp.models import Article
|
from myapp.models import Article
|
||||||
|
|
||||||
def make_published(request, queryset):
|
def make_published(modeladmin, request, queryset):
|
||||||
queryset.update(status='p')
|
queryset.update(status='p')
|
||||||
make_published.short_description = "Mark selected stories as published"
|
make_published.short_description = "Mark selected stories as published"
|
||||||
|
|
||||||
|
@ -150,14 +155,14 @@ That's easy enough to do::
|
||||||
queryset.update(status='p')
|
queryset.update(status='p')
|
||||||
make_published.short_description = "Mark selected stories as published"
|
make_published.short_description = "Mark selected stories as published"
|
||||||
|
|
||||||
Notice first that we've moved ``make_published`` into a method (remembering to
|
Notice first that we've moved ``make_published`` into a method and renamed the
|
||||||
add the ``self`` argument!), and second that we've now put the string
|
`modeladmin` parameter to `self`, and second that we've now put the string
|
||||||
``'make_published'`` in ``actions`` instead of a direct function reference.
|
``'make_published'`` in ``actions`` instead of a direct function reference. This
|
||||||
This tells the :class:`ModelAdmin` to look up the action as a method.
|
tells the :class:`ModelAdmin` to look up the action as a method.
|
||||||
|
|
||||||
Defining actions as methods is especially nice because it gives the action
|
Defining actions as methods is gives the action more straightforward, idiomatic
|
||||||
access to the :class:`ModelAdmin` itself, allowing the action to call any of
|
access to the :class:`ModelAdmin` itself, allowing the action to call any of the
|
||||||
the methods provided by the admin.
|
methods provided by the admin.
|
||||||
|
|
||||||
For example, we can use ``self`` to flash a message to the user informing her
|
For example, we can use ``self`` to flash a message to the user informing her
|
||||||
that the action was successful::
|
that the action was successful::
|
||||||
|
@ -208,8 +213,8 @@ 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
|
This allows you to provide complex interaction logic on the intermediary
|
||||||
pages. For example, if you wanted to provide a more complete export function,
|
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
|
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
|
include in the export. The best thing to do would be to write a small action
|
||||||
to your custom export view::
|
that simply redirects to your custom export view::
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.contrib.contenttypes.models import ContentType
|
from django.contrib.contenttypes.models import ContentType
|
||||||
|
@ -226,14 +231,108 @@ hence the business with the ``ContentType``.
|
||||||
|
|
||||||
Writing this view is left as an exercise to the reader.
|
Writing this view is left as an exercise to the reader.
|
||||||
|
|
||||||
Making actions available globally
|
.. _adminsite-actions:
|
||||||
---------------------------------
|
|
||||||
|
|
||||||
Some actions are best if they're made available to *any* object in the admin
|
Making actions available site-wide
|
||||||
-- 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
|
.. method:: AdminSite.add_action(action[, name])
|
||||||
|
|
||||||
|
Some actions are best if they're made available to *any* object in the admin
|
||||||
|
site -- the export action defined above would be a good candidate. You can
|
||||||
|
make an action globally available using :meth:`AdminSite.add_action()`. For
|
||||||
|
example::
|
||||||
|
|
||||||
|
from django.contrib import admin
|
||||||
|
|
||||||
admin.site.add_action(export_selected_objects)
|
admin.site.add_action(export_selected_objects)
|
||||||
|
|
||||||
|
This makes the `export_selected_objects` action globally available as an
|
||||||
|
action named `"export_selected_objects"`. You can explicitly give the action
|
||||||
|
a name -- good if you later want to programatically :ref:`remove the action
|
||||||
|
<disabling-admin-actions>` -- by passing a second argument to
|
||||||
|
:meth:`AdminSite.add_action()`::
|
||||||
|
|
||||||
|
admin.site.add_action(export_selected_objects, 'export_selected')
|
||||||
|
|
||||||
|
.. _disabling-admin-actions:
|
||||||
|
|
||||||
|
Disabling actions
|
||||||
|
-----------------
|
||||||
|
|
||||||
|
Sometimes you need to disable certain actions -- especially those
|
||||||
|
:ref:`registered site-wide <adminsite-actions>` -- for particular objects.
|
||||||
|
There's a few ways you can disable actions:
|
||||||
|
|
||||||
|
Disabling a site-wide action
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. method:: AdminSite.disable_action(name)
|
||||||
|
|
||||||
|
If you need to disable a :ref:`site-wide action <adminsite-actions>` you can
|
||||||
|
call :meth:`AdminSite.disable_action()`.
|
||||||
|
|
||||||
|
For example, you can use this method to remove the built-in "delete selected
|
||||||
|
objects" action::
|
||||||
|
|
||||||
|
admin.site.disable_action('delete_selected')
|
||||||
|
|
||||||
|
Once you've done the above, that action will no longer be available
|
||||||
|
site-wide.
|
||||||
|
|
||||||
|
If, however, you need to re-enable a globally-disabled action for one
|
||||||
|
particular model, simply list it explicitally in your ``ModelAdmin.actions``
|
||||||
|
list::
|
||||||
|
|
||||||
|
# Globally disable delete selected
|
||||||
|
admin.site.disable_action('delete_selected')
|
||||||
|
|
||||||
|
# This ModelAdmin will not have delete_selected available
|
||||||
|
class SomeModelAdmin(admin.ModelAdmin):
|
||||||
|
actions = ['some_other_action']
|
||||||
|
...
|
||||||
|
|
||||||
|
# This one will
|
||||||
|
class AnotherModelAdmin(admin.ModelAdmin):
|
||||||
|
actions = ['delete_selected', 'a_third_action']
|
||||||
|
...
|
||||||
|
|
||||||
|
|
||||||
|
Disabling all actions for a particular :class:`ModelAdmin`
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
If you want *no* bulk actions available for a given :class:`ModelAdmin`, simply
|
||||||
|
set :attr:`ModelAdmin.actions` to ``None``::
|
||||||
|
|
||||||
|
class MyModelAdmin(admin.ModelAdmin):
|
||||||
|
actions = None
|
||||||
|
|
||||||
|
This tells the :class:`ModelAdmin` to not display or allow any actions,
|
||||||
|
including any :ref:`site-wide actions <adminsite-actions>`.
|
||||||
|
|
||||||
|
Conditionally enabling or disabling actions
|
||||||
|
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
|
||||||
|
|
||||||
|
.. method:: ModelAdmin.get_actions(request)
|
||||||
|
|
||||||
|
Finally, you can conditionally enable or disable actions on a per-request
|
||||||
|
(and hence per-user basis) by overriding :meth:`ModelAdmin.get_actions`.
|
||||||
|
|
||||||
|
This returns a dictionary of actions allowed. The keys are action names, and
|
||||||
|
the values are ``(function, name, short_description)`` tuples.
|
||||||
|
|
||||||
|
Most of the time you'll use this method to conditionally remove actions from
|
||||||
|
the list gathered by the superclass. For example, if I only wanted users
|
||||||
|
whose names begin with 'J' to be able to delete objects in bulk, I could do
|
||||||
|
the following::
|
||||||
|
|
||||||
|
class MyModelAdmin(admin.ModelAdmin):
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_actions(self, request):
|
||||||
|
actions = super(MyModelAdmin, self).get_actions(request)
|
||||||
|
if request.user.username[0].upper() != 'J':
|
||||||
|
del actions['delete_selected']
|
||||||
|
return actions
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -223,7 +223,7 @@ class Subscriber(models.Model):
|
||||||
return "%s (%s)" % (self.name, self.email)
|
return "%s (%s)" % (self.name, self.email)
|
||||||
|
|
||||||
class SubscriberAdmin(admin.ModelAdmin):
|
class SubscriberAdmin(admin.ModelAdmin):
|
||||||
actions = ['delete_selected', 'mail_admin']
|
actions = ['mail_admin']
|
||||||
|
|
||||||
def mail_admin(self, request, selected):
|
def mail_admin(self, request, selected):
|
||||||
EmailMessage(
|
EmailMessage(
|
||||||
|
@ -236,7 +236,10 @@ class SubscriberAdmin(admin.ModelAdmin):
|
||||||
class ExternalSubscriber(Subscriber):
|
class ExternalSubscriber(Subscriber):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def external_mail(request, selected):
|
class OldSubscriber(Subscriber):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def external_mail(modeladmin, request, selected):
|
||||||
EmailMessage(
|
EmailMessage(
|
||||||
'Greetings from a function action',
|
'Greetings from a function action',
|
||||||
'This is the test email from a function action',
|
'This is the test email from a function action',
|
||||||
|
@ -244,7 +247,7 @@ def external_mail(request, selected):
|
||||||
['to@example.com']
|
['to@example.com']
|
||||||
).send()
|
).send()
|
||||||
|
|
||||||
def redirect_to(request, selected):
|
def redirect_to(modeladmin, request, selected):
|
||||||
from django.http import HttpResponseRedirect
|
from django.http import HttpResponseRedirect
|
||||||
return HttpResponseRedirect('/some-where-else/')
|
return HttpResponseRedirect('/some-where-else/')
|
||||||
|
|
||||||
|
@ -285,6 +288,9 @@ class EmptyModelAdmin(admin.ModelAdmin):
|
||||||
def queryset(self, request):
|
def queryset(self, request):
|
||||||
return super(EmptyModelAdmin, self).queryset(request).filter(pk__gt=1)
|
return super(EmptyModelAdmin, self).queryset(request).filter(pk__gt=1)
|
||||||
|
|
||||||
|
class OldSubscriberAdmin(admin.ModelAdmin):
|
||||||
|
actions = None
|
||||||
|
|
||||||
admin.site.register(Article, ArticleAdmin)
|
admin.site.register(Article, ArticleAdmin)
|
||||||
admin.site.register(CustomArticle, CustomArticleAdmin)
|
admin.site.register(CustomArticle, CustomArticleAdmin)
|
||||||
admin.site.register(Section, save_as=True, inlines=[ArticleInline])
|
admin.site.register(Section, save_as=True, inlines=[ArticleInline])
|
||||||
|
@ -295,6 +301,7 @@ admin.site.register(Person, PersonAdmin)
|
||||||
admin.site.register(Persona, PersonaAdmin)
|
admin.site.register(Persona, PersonaAdmin)
|
||||||
admin.site.register(Subscriber, SubscriberAdmin)
|
admin.site.register(Subscriber, SubscriberAdmin)
|
||||||
admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
|
admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
|
||||||
|
admin.site.register(OldSubscriber, OldSubscriberAdmin)
|
||||||
admin.site.register(Podcast, PodcastAdmin)
|
admin.site.register(Podcast, PodcastAdmin)
|
||||||
admin.site.register(Parent, ParentAdmin)
|
admin.site.register(Parent, ParentAdmin)
|
||||||
admin.site.register(EmptyModel, EmptyModelAdmin)
|
admin.site.register(EmptyModel, EmptyModelAdmin)
|
||||||
|
|
|
@ -995,6 +995,32 @@ class AdminActionsTest(TestCase):
|
||||||
}
|
}
|
||||||
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
|
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
|
||||||
self.failUnlessEqual(response.status_code, 302)
|
self.failUnlessEqual(response.status_code, 302)
|
||||||
|
|
||||||
|
def test_model_without_action(self):
|
||||||
|
"Tests a ModelAdmin without any action"
|
||||||
|
response = self.client.get('/test_admin/admin/admin_views/oldsubscriber/')
|
||||||
|
self.assertEquals(response.context["action_form"], None)
|
||||||
|
self.assert_(
|
||||||
|
'<input type="checkbox" class="action-select"' not in response.content,
|
||||||
|
"Found an unexpected action toggle checkboxbox in response"
|
||||||
|
)
|
||||||
|
|
||||||
|
def test_multiple_actions_form(self):
|
||||||
|
"""
|
||||||
|
Test that actions come from the form whose submit button was pressed (#10618).
|
||||||
|
"""
|
||||||
|
action_data = {
|
||||||
|
ACTION_CHECKBOX_NAME: [1],
|
||||||
|
# Two different actions selected on the two forms...
|
||||||
|
'action': ['external_mail', 'delete_selected'],
|
||||||
|
# ...but we clicked "go" on the top form.
|
||||||
|
'index': 0
|
||||||
|
}
|
||||||
|
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
|
||||||
|
|
||||||
|
# Send mail, don't delete.
|
||||||
|
self.assertEquals(len(mail.outbox), 1)
|
||||||
|
self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
|
||||||
|
|
||||||
class TestInlineNotEditable(TestCase):
|
class TestInlineNotEditable(TestCase):
|
||||||
fixtures = ['admin-views-users.xml']
|
fixtures = ['admin-views-users.xml']
|
||||||
|
|
Loading…
Reference in New Issue