Author: jacob
Date: 2009-04-06 15:23:33 -0500 (Mon, 06 Apr 2009)
New Revision: 10408

Added:
   django/trunk/django/contrib/admin/actions.py
Modified:
   django/trunk/django/contrib/admin/options.py
   django/trunk/django/contrib/admin/sites.py
   django/trunk/django/contrib/admin/templates/admin/change_list.html
   django/trunk/django/contrib/admin/templates/admin/pagination.html
   django/trunk/django/contrib/admin/validation.py
   django/trunk/docs/ref/contrib/admin/actions.txt
   django/trunk/tests/regressiontests/admin_views/models.py
   django/trunk/tests/regressiontests/admin_views/tests.py
Log:
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.

Added: django/trunk/django/contrib/admin/actions.py
===================================================================
--- django/trunk/django/contrib/admin/actions.py                                
(rev 0)
+++ django/trunk/django/contrib/admin/actions.py        2009-04-06 20:23:33 UTC 
(rev 10408)
@@ -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")

Modified: django/trunk/django/contrib/admin/options.py
===================================================================
--- django/trunk/django/contrib/admin/options.py        2009-04-05 21:45:07 UTC 
(rev 10407)
+++ django/trunk/django/contrib/admin/options.py        2009-04-06 20:23:33 UTC 
(rev 10408)
@@ -11,6 +11,7 @@
 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 @@
     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 @@
         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 @@
         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 @@
     action_checkbox.short_description = mark_safe('<input type="checkbox" 
id="action-toggle" />')
     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 @@
         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: <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):
         """
         Construct a change message from a changed object.
@@ -678,6 +651,16 @@
         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 @@
             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 @@
         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 @@
             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 @@
             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,

Modified: django/trunk/django/contrib/admin/sites.py
===================================================================
--- django/trunk/django/contrib/admin/sites.py  2009-04-05 21:45:07 UTC (rev 
10407)
+++ django/trunk/django/contrib/admin/sites.py  2009-04-06 20:23:33 UTC (rev 
10408)
@@ -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.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,9 +49,9 @@
         else:
             name += '_'
         self.name = name
+        self._actions = {'delete_selected': actions.delete_selected}
+        self._global_actions = self._actions.copy()
 
-        self.actions = []
-
     def register(self, model_or_iterable, admin_class=None, **options):
         """
         Registers the given model(s) with the given admin class.
@@ -102,10 +107,33 @@
                 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):
         """

Modified: django/trunk/django/contrib/admin/templates/admin/change_list.html
===================================================================
--- django/trunk/django/contrib/admin/templates/admin/change_list.html  
2009-04-05 21:45:07 UTC (rev 10407)
+++ django/trunk/django/contrib/admin/templates/admin/change_list.html  
2009-04-06 20:23:33 UTC (rev 10408)
@@ -9,6 +9,11 @@
     <script type="text/javascript" src="../../jsi18n/"></script>
   {% endif %}
   {{ media }}
+  {% if not actions_on_top and not actions_on_bottom %}
+    <style>
+      #changelist table thead th:first-child {width: inherit}
+    </style>
+  {% 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 %}
       </form>

Modified: django/trunk/django/contrib/admin/templates/admin/pagination.html
===================================================================
--- django/trunk/django/contrib/admin/templates/admin/pagination.html   
2009-04-05 21:45:07 UTC (rev 10407)
+++ django/trunk/django/contrib/admin/templates/admin/pagination.html   
2009-04-06 20:23:33 UTC (rev 10408)
@@ -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 %}&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>

Modified: django/trunk/django/contrib/admin/validation.py
===================================================================
--- django/trunk/django/contrib/admin/validation.py     2009-04-05 21:45:07 UTC 
(rev 10407)
+++ django/trunk/django/contrib/admin/validation.py     2009-04-06 20:23:33 UTC 
(rev 10408)
@@ -127,14 +127,6 @@
                 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

Modified: django/trunk/docs/ref/contrib/admin/actions.txt
===================================================================
--- django/trunk/docs/ref/contrib/admin/actions.txt     2009-04-05 21:45:07 UTC 
(rev 10407)
+++ django/trunk/docs/ref/contrib/admin/actions.txt     2009-04-06 20:23:33 UTC 
(rev 10408)
@@ -31,8 +31,8 @@
 
 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 @@
 
 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 @@
 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 @@
     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 @@
             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 @@
 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 @@
 
 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
+    <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
+    
+

Modified: django/trunk/tests/regressiontests/admin_views/models.py
===================================================================
--- django/trunk/tests/regressiontests/admin_views/models.py    2009-04-05 
21:45:07 UTC (rev 10407)
+++ django/trunk/tests/regressiontests/admin_views/models.py    2009-04-06 
20:23:33 UTC (rev 10408)
@@ -223,7 +223,7 @@
         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 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 @@
         ['t...@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 @@
     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(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)

Modified: django/trunk/tests/regressiontests/admin_views/tests.py
===================================================================
--- django/trunk/tests/regressiontests/admin_views/tests.py     2009-04-05 
21:45:07 UTC (rev 10407)
+++ django/trunk/tests/regressiontests/admin_views/tests.py     2009-04-06 
20:23:33 UTC (rev 10408)
@@ -995,7 +995,33 @@
         }
         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_(
+            '<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):
     fixtures = ['admin-views-users.xml']
 


--~--~---------~--~----~------------~-------~--~----~
You received this message because you are subscribed to the Google Groups 
"Django updates" group.
To post to this group, send email to django-updates@googlegroups.com
To unsubscribe from this group, send email to 
django-updates+unsubscr...@googlegroups.com
For more options, visit this group at 
http://groups.google.com/group/django-updates?hl=en
-~----------~----~----~----~------~----~------~--~---

Reply via email to