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:
Jacob Kaplan-Moss 2009-04-06 20:23:33 +00:00
parent d0c897d660
commit bb15cee58a
9 changed files with 367 additions and 131 deletions

View File

@ -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")

View File

@ -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,

View File

@ -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):
""" """

View File

@ -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>

View File

@ -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 %}&nbsp;&nbsp;<a href="{{ show_all_url }}" class="showall">{% trans 'Show all' %}</a>{% endif %} {% if show_all_url %}&nbsp;&nbsp;<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>

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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']