From bb15cee58a43eeb0d060f8a31f9078b3406f195a Mon Sep 17 00:00:00 2001
From: Jacob Kaplan-Moss
Date: Mon, 6 Apr 2009 20:23:33 +0000
Subject: [PATCH] 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
---
django/contrib/admin/actions.py | 81 +++++++++
django/contrib/admin/options.py | 170 +++++++++---------
django/contrib/admin/sites.py | 40 ++++-
.../admin/templates/admin/change_list.html | 9 +-
.../admin/templates/admin/pagination.html | 2 +-
django/contrib/admin/validation.py | 8 -
docs/ref/contrib/admin/actions.txt | 149 ++++++++++++---
tests/regressiontests/admin_views/models.py | 13 +-
tests/regressiontests/admin_views/tests.py | 26 +++
9 files changed, 367 insertions(+), 131 deletions(-)
create mode 100644 django/contrib/admin/actions.py
diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py
new file mode 100644
index 0000000000..d35319435d
--- /dev/null
+++ b/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: %s' % (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")
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 7cb1aac959..8035caf1db 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -11,6 +11,7 @@ from django.db import models, transaction
from django.db.models.fields import BLANK_CHOICE_DASH
from django.http import Http404, HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render_to_response
+from django.utils.datastructures import SortedDict
from django.utils.functional import update_wrapper
from django.utils.html import escape
from django.utils.safestring import mark_safe
@@ -194,7 +195,7 @@ class ModelAdmin(BaseModelAdmin):
object_history_template = None
# Actions
- actions = ['delete_selected']
+ actions = []
action_form = helpers.ActionForm
actions_on_top = True
actions_on_bottom = False
@@ -207,7 +208,7 @@ class ModelAdmin(BaseModelAdmin):
for inline_class in self.inlines:
inline_instance = inline_class(self.model, self.admin_site)
self.inline_instances.append(inline_instance)
- if 'action_checkbox' not in self.list_display:
+ if 'action_checkbox' not in self.list_display and self.actions is not None:
self.list_display = ['action_checkbox'] + list(self.list_display)
if not self.list_display_links:
for name in self.list_display:
@@ -253,7 +254,7 @@ class ModelAdmin(BaseModelAdmin):
from django.conf import settings
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'])
if self.prepopulated_fields:
js.append('js/urlify.js')
@@ -414,19 +415,46 @@ class ModelAdmin(BaseModelAdmin):
action_checkbox.short_description = mark_safe('')
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
ModelAdmin to a tuple of (callable, name, description) for each action.
"""
- actions = {}
- for klass in [self.admin_site] + self.__class__.mro()[::-1]:
- for action in getattr(klass, 'actions', []):
- func, name, description = self.get_action(action)
- actions[name] = (func, name, description)
+ # If self.actions is explicitally set to None that means that we don't
+ # want *any* actions enabled on this page.
+ if self.actions is None:
+ return []
+
+ 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
- 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
tuple (name, description).
@@ -443,85 +471,30 @@ class ModelAdmin(BaseModelAdmin):
or the name of a method on the ModelAdmin. Return is a tuple of
(callable, name, description).
"""
+ # If the action is a callable, just use it.
if callable(action):
func = action
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'):
description = func.short_description
else:
description = capfirst(action.replace('_', ' '))
return func, action, description
- def delete_selected(self, request, queryset):
- """
- Default action which deletes the selected objects.
-
- In the first step, it displays a confirmation page whichs shows all
- the deleteable objects or, if the user has no permission one of the
- related childs (foreignkeys) it displays a "permission denied" message.
-
- In the second step delete all selected objects and display the change
- list again.
- """
- opts = self.model._meta
- app_label = opts.app_label
-
- # Check that the user has delete permission for the actual model
- if not self.has_delete_permission(request):
- raise PermissionDenied
-
- # Populate deletable_objects, a data structure of all related objects that
- # will also be deleted.
-
- # deletable_objects must be a list if we want to use '|unordered_list' in the template
- deletable_objects = []
- perms_needed = set()
- i = 0
- for obj in queryset:
- deletable_objects.append([mark_safe(u'%s: %s' % (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):
"""
Construct a change message from a changed object.
@@ -678,6 +651,16 @@ class ModelAdmin(BaseModelAdmin):
data = request.POST.copy()
data.pop(helpers.ACTION_CHECKBOX_NAME, 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.fields['action'].choices = self.get_action_choices(request)
@@ -692,7 +675,7 @@ class ModelAdmin(BaseModelAdmin):
if not selected:
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
# 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
if not self.has_change_permission(request, None):
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:
- 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)
except IncorrectLookupParameters:
# Wacky lookup parameters were given, so redirect to the main
@@ -893,11 +888,11 @@ class ModelAdmin(BaseModelAdmin):
if ERROR_FLAG in request.GET.keys():
return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
-
+
# If the request was POSTed, this might be a bulk action or a bulk edit.
# Try to look up an action first, but if this isn't an action the POST
# will fall through to the bulk edit check, below.
- if request.method == 'POST':
+ if actions and request.method == 'POST':
response = self.response_action(request, queryset=cl.get_query_set())
if response:
return response
@@ -948,8 +943,11 @@ class ModelAdmin(BaseModelAdmin):
media = self.media
# Build the action form and populate it with available actions.
- action_form = self.action_form(auto_id=None)
- action_form.fields['action'].choices = self.get_action_choices(request)
+ if actions:
+ action_form = self.action_form(auto_id=None)
+ action_form.fields['action'].choices = self.get_action_choices(request)
+ else:
+ action_form = None
context = {
'title': cl.title,
diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py
index 8b579853d4..4e1fb37021 100644
--- a/django/contrib/admin/sites.py
+++ b/django/contrib/admin/sites.py
@@ -1,6 +1,7 @@
import re
from django import http, template
from django.contrib.admin import ModelAdmin
+from django.contrib.admin import actions
from django.contrib.auth import authenticate, login
from django.db.models.base import ModelBase
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.views.decorators.cache import never_cache
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.")
LOGIN_FORM_KEY = 'this_is_the_login_form'
@@ -44,8 +49,8 @@ class AdminSite(object):
else:
name += '_'
self.name = name
-
- self.actions = []
+ self._actions = {'delete_selected': actions.delete_selected}
+ self._global_actions = self._actions.copy()
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__)
del self._registry[model]
- def add_action(self, action):
- if not callable(action):
- raise TypeError("You can only register callable actions through an admin site")
- self.actions.append(action)
+ def add_action(self, action, name=None):
+ """
+ Register an action to be available globally.
+ """
+ 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):
"""
diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
index 63254b868e..31bf7bd29a 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -9,6 +9,11 @@
{% endif %}
{{ media }}
+ {% if not actions_on_top and not actions_on_bottom %}
+
+ {% endif %}
{% endblock %}
{% block bodyclass %}change-list{% endblock %}
@@ -69,9 +74,9 @@
{% endif %}
{% 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 %}
- {% 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 %}
{% block pagination %}{% pagination cl %}{% endblock %}
diff --git a/django/contrib/admin/templates/admin/pagination.html b/django/contrib/admin/templates/admin/pagination.html
index 58ade6ad0c..aaba97fdb7 100644
--- a/django/contrib/admin/templates/admin/pagination.html
+++ b/django/contrib/admin/templates/admin/pagination.html
@@ -8,5 +8,5 @@
{% endif %}
{{ cl.result_count }} {% ifequal cl.result_count 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endifequal %}
{% if show_all_url %} {% trans 'Show all' %}{% endif %}
-{% if cl.formset %}{% endif %}
+{% if cl.formset and cl.result_count %}{% endif %}
diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py
index 4fdbd0d6f4..3392714f5d 100644
--- a/django/contrib/admin/validation.py
+++ b/django/contrib/admin/validation.py
@@ -127,14 +127,6 @@ def validate(cls, model):
continue
get_field(cls, model, opts, 'ordering[%d]' % idx, field)
- if cls.actions:
- check_isseq(cls, 'actions', cls.actions)
- for idx, item in enumerate(cls.actions):
- if (not callable(item)) and (not hasattr(cls, item)):
- raise ImproperlyConfigured("'%s.actions[%d]' is neither a "
- "callable nor a method on %s" % (cls.__name__, idx, cls.__name__))
-
-
# list_select_related = False
# save_as = False
# save_on_top = False
diff --git a/docs/ref/contrib/admin/actions.txt b/docs/ref/contrib/admin/actions.txt
index 4969e97a99..2bc75c0b6e 100644
--- a/docs/ref/contrib/admin/actions.txt
+++ b/docs/ref/contrib/admin/actions.txt
@@ -31,8 +31,8 @@ Writing actions
The easiest way to explain actions is by example, so let's dive in.
-A common use case for admin actions is the bulk updating of a model. Imagine a simple
-news application with an ``Article`` model::
+A common use case for admin actions is the bulk updating of a model. Imagine a
+simple news application with an ``Article`` model::
from django.db import models
@@ -61,12 +61,17 @@ Writing action functions
First, we'll need to write a function that gets called when the action is
trigged from the admin. Action functions are just regular functions that take
-two arguments: an :class:`~django.http.HttpRequest` representing the current
-request, and a :class:`~django.db.models.QuerySet` containing the set of
-objects selected by the user. Our publish-these-articles function won't need
-the request object, but we will use the queryset::
+three arguments:
+
+ * The current :class:`ModelAdmin`
+ * 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')
.. 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
``make_published`` function a ``short_description`` attribute::
- def make_published(request, queryset):
+ def make_published(modeladmin, request, queryset):
queryset.update(status='p')
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 myapp.models import Article
- def make_published(request, queryset):
+ def make_published(modeladmin, request, queryset):
queryset.update(status='p')
make_published.short_description = "Mark selected stories as published"
@@ -150,14 +155,14 @@ That's easy enough to do::
queryset.update(status='p')
make_published.short_description = "Mark selected stories as published"
-Notice first that we've moved ``make_published`` into a method (remembering to
-add the ``self`` argument!), and second that we've now put the string
-``'make_published'`` in ``actions`` instead of a direct function reference.
-This tells the :class:`ModelAdmin` to look up the action as a method.
+Notice first that we've moved ``make_published`` into a method and renamed the
+`modeladmin` parameter to `self`, and second that we've now put the string
+``'make_published'`` in ``actions`` instead of a direct function reference. This
+tells the :class:`ModelAdmin` to look up the action as a method.
-Defining actions as methods is especially nice because it gives the action
-access to the :class:`ModelAdmin` itself, allowing the action to call any of
-the methods provided by the admin.
+Defining actions as methods is gives the action more straightforward, idiomatic
+access to the :class:`ModelAdmin` itself, allowing the action to call any of the
+methods provided by the admin.
For example, we can use ``self`` to flash a message to the user informing her
that the action was successful::
@@ -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
pages. For example, if you wanted to provide a more complete export function,
you'd want to let the user choose a format, and possibly a list of fields to
-include in the export. The best thing to do would be to write a small action that simply redirects
-to your custom export view::
+include in the export. The best thing to do would be to write a small action
+that simply redirects to your custom export view::
from django.contrib import admin
from django.contrib.contenttypes.models import ContentType
@@ -226,14 +231,108 @@ hence the business with the ``ContentType``.
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
--- the export action defined above would be a good candidate. You can make an
-action globally available using :meth:`AdminSite.add_action()`::
+Making actions available site-wide
+----------------------------------
- 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
+ ` -- 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 ` -- 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 ` 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 `.
+
+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
+
+
diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
index 74fc7ecf78..75b4ad2c87 100644
--- a/tests/regressiontests/admin_views/models.py
+++ b/tests/regressiontests/admin_views/models.py
@@ -223,7 +223,7 @@ class Subscriber(models.Model):
return "%s (%s)" % (self.name, self.email)
class SubscriberAdmin(admin.ModelAdmin):
- actions = ['delete_selected', 'mail_admin']
+ actions = ['mail_admin']
def mail_admin(self, request, selected):
EmailMessage(
@@ -236,7 +236,10 @@ class SubscriberAdmin(admin.ModelAdmin):
class ExternalSubscriber(Subscriber):
pass
-def external_mail(request, selected):
+class OldSubscriber(Subscriber):
+ pass
+
+def external_mail(modeladmin, request, selected):
EmailMessage(
'Greetings 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']
).send()
-def redirect_to(request, selected):
+def redirect_to(modeladmin, request, selected):
from django.http import HttpResponseRedirect
return HttpResponseRedirect('/some-where-else/')
@@ -285,6 +288,9 @@ class EmptyModelAdmin(admin.ModelAdmin):
def queryset(self, request):
return super(EmptyModelAdmin, self).queryset(request).filter(pk__gt=1)
+class OldSubscriberAdmin(admin.ModelAdmin):
+ actions = None
+
admin.site.register(Article, ArticleAdmin)
admin.site.register(CustomArticle, CustomArticleAdmin)
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(Subscriber, SubscriberAdmin)
admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
+admin.site.register(OldSubscriber, OldSubscriberAdmin)
admin.site.register(Podcast, PodcastAdmin)
admin.site.register(Parent, ParentAdmin)
admin.site.register(EmptyModel, EmptyModelAdmin)
diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
index 7c54c73346..d7bce8fdcd 100644
--- a/tests/regressiontests/admin_views/tests.py
+++ b/tests/regressiontests/admin_views/tests.py
@@ -995,6 +995,32 @@ class AdminActionsTest(TestCase):
}
response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
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_(
+ '