Skip to content

Commit

Permalink
Made a bunch of improvements to admin actions. Be warned: this includ…
Browse files Browse the repository at this point in the history
…es 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
  • Loading branch information
jacobian committed Apr 6, 2009
1 parent d0c897d commit bb15cee
Show file tree
Hide file tree
Showing 9 changed files with 367 additions and 131 deletions.
81 changes: 81 additions & 0 deletions django/contrib/admin/actions.py
@@ -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")
170 changes: 84 additions & 86 deletions django/contrib/admin/options.py
Expand Up @@ -11,6 +11,7 @@
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
Expand Down Expand Up @@ -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
Expand All @@ -207,7 +208,7 @@ def __init__(self, model, admin_site):
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:
Expand Down Expand Up @@ -253,7 +254,7 @@ def _media(self):
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')
Expand Down Expand Up @@ -414,19 +415,46 @@ def action_checkbox(self, obj):
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).
Expand All @@ -443,85 +471,30 @@ def get_action(self, action):
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.
Expand Down Expand Up @@ -678,6 +651,16 @@ def response_action(self, request, queryset):
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)


Expand All @@ -692,7 +675,7 @@ def response_action(self, request, queryset):
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
Expand Down Expand Up @@ -881,8 +864,20 @@ def changelist_view(self, request, extra_context=None):
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
Expand All @@ -893,11 +888,11 @@ def changelist_view(self, request, extra_context=None):
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
Expand Down Expand Up @@ -948,8 +943,11 @@ def changelist_view(self, request, extra_context=None):
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,
Expand Down

0 comments on commit bb15cee

Please sign in to comment.