Przeglądaj źródła

Fixed #10505: added support for bulk admin actions, including a globally-available "delete selected" action. See the documentation for details.

This work started life as Brian Beck's "django-batchadmin." It was rewritten for inclusion in Django by Alex Gaynor, Jannis Leidel (jezdez), and Martin Mahner (bartTC). Thanks, guys!

git-svn-id: http://code.djangoproject.com/svn/django/trunk@10121 bcc190cf-cafb-0310-a4f2-bffc1f526a37
Jacob Kaplan-Moss 16 lat temu
rodzic
commit
44f3080226

+ 2 - 0
AUTHORS

@@ -56,6 +56,7 @@ answer newbie questions, and generally made Django that much better:
     Ned Batchelder <http://www.nedbatchelder.com/>
     batiste@dosimple.ch
     Batman
+    Brian Beck <http://blog.brianbeck.com/>
     Shannon -jj Behrens <http://jjinux.blogspot.com/>
     Esdras Beleza <linux@esdrasbeleza.com>
     Chris Bennett <chrisrbennett@yahoo.com>
@@ -268,6 +269,7 @@ answer newbie questions, and generally made Django that much better:
     Daniel Lindsley <polarcowz@gmail.com>
     Trey Long <trey@ktrl.com>
     msaelices <msaelices@gmail.com>
+    Martin Mahner <http://www.mahner.org/>
     Matt McClanahan <http://mmcc.cx/>
     Frantisek Malina <vizualbod@vizualbod.com>
     Martin Maney <http://www.chipy.org/Martin_Maney>

+ 1 - 0
django/contrib/admin/__init__.py

@@ -1,3 +1,4 @@
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.options import ModelAdmin, HORIZONTAL, VERTICAL
 from django.contrib.admin.options import StackedInline, TabularInline
 from django.contrib.admin.sites import AdminSite, site

+ 13 - 5
django/contrib/admin/helpers.py

@@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
 from django.utils.encoding import force_unicode
 from django.contrib.admin.util import flatten_fieldsets
 from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import ugettext_lazy as _
+
+ACTION_CHECKBOX_NAME = '_selected_action'
+
+class ActionForm(forms.Form):
+    action = forms.ChoiceField(label=_('Action:'))
+
+checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
 
 class AdminForm(object):
     def __init__(self, form, fieldsets, prepopulated_fields):
@@ -132,11 +140,11 @@ class InlineAdminForm(AdminForm):
             self.original.content_type_id = ContentType.objects.get_for_model(original).pk
         self.show_url = original and hasattr(original, 'get_absolute_url')
         super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
-    
+
     def __iter__(self):
         for name, options in self.fieldsets:
             yield InlineFieldset(self.formset, self.form, name, **options)
-    
+
     def field_count(self):
         # tabular.html uses this function for colspan value.
         num_of_fields = 1 # always has at least one field
@@ -149,7 +157,7 @@ class InlineAdminForm(AdminForm):
 
     def pk_field(self):
         return AdminField(self.form, self.formset._pk_field.name, False)
-    
+
     def fk_field(self):
         fk = getattr(self.formset, "fk", None)
         if fk:
@@ -169,14 +177,14 @@ class InlineFieldset(Fieldset):
     def __init__(self, formset, *args, **kwargs):
         self.formset = formset
         super(InlineFieldset, self).__init__(*args, **kwargs)
-        
+
     def __iter__(self):
         fk = getattr(self.formset, "fk", None)
         for field in self.fields:
             if fk and fk.name == field:
                 continue
             yield Fieldline(self.form, field)
-            
+
 class AdminErrorList(forms.util.ErrorList):
     """
     Stores all errors for the form/formsets in an add/change stage view.

+ 44 - 0
django/contrib/admin/media/css/changelists.css

@@ -50,12 +50,24 @@
 
 #changelist table thead th {
     white-space: nowrap;
+    vertical-align: middle;
+}
+
+#changelist table thead th:first-child {
+    width: 1.5em;
+    text-align: center;
 }
 
 #changelist table tbody td {
     border-left: 1px solid #ddd;
 }
 
+#changelist table tbody td:first-child {
+    border-left: 0;
+    border-right: 1px solid #ddd;
+    text-align: center;
+}
+
 #changelist table tfoot {
     color: #666;
 }
@@ -209,3 +221,35 @@
     border-color: #036;
 }
 
+/* ACTIONS */
+
+.filtered .actions {
+    margin-right: 160px !important;
+    border-right: 1px solid #ddd;
+}
+
+#changelist .actions {
+    color: #666;
+    padding: 3px;
+    border-bottom: 1px solid #ddd;
+    background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
+}
+
+#changelist .actions:last-child {
+    border-bottom: none;
+}
+
+#changelist .actions select {
+    border: 1px solid #aaa;
+    margin: 0 0.5em;
+    padding: 1px 2px;
+}
+
+#changelist .actions label {
+    font-size: 11px;
+    margin: 0 0.5em;
+}
+
+#changelist #action-toggle {
+    display: none;
+}

+ 19 - 0
django/contrib/admin/media/js/actions.js

@@ -0,0 +1,19 @@
+var Actions = {
+    init: function() {
+        selectAll = document.getElementById('action-toggle');
+        if (selectAll) {
+            selectAll.style.display = 'inline';
+            addEvent(selectAll, 'change', function() {
+                Actions.checker(this.checked);
+            });
+        }
+    },
+    checker: function(checked) {
+        actionCheckboxes = document.getElementsBySelector('tr input.action-select');
+        for(var i = 0; i < actionCheckboxes.length; i++) {
+            actionCheckboxes[i].checked = checked;
+        }
+    }
+}
+
+addEvent(window, 'load', Actions.init);

+ 191 - 3
django/contrib/admin/options.py

@@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.admin import widgets
 from django.contrib.admin import helpers
-from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
+from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
 from django.core.exceptions import PermissionDenied
 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.functional import update_wrapper
@@ -16,7 +17,7 @@ from django.utils.safestring import mark_safe
 from django.utils.functional import curry
 from django.utils.text import capfirst, get_text_list
 from django.utils.translation import ugettext as _
-from django.utils.translation import ngettext
+from django.utils.translation import ngettext, ugettext_lazy
 from django.utils.encoding import force_unicode
 try:
     set
@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
     delete_confirmation_template = None
     object_history_template = None
 
+    # Actions
+    actions = ['delete_selected']
+    action_form = helpers.ActionForm
+    actions_on_top = True
+    actions_on_bottom = False
+
     def __init__(self, model, admin_site):
         self.model = model
         self.opts = model._meta
@@ -200,6 +207,13 @@ 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:
+            self.list_display = ['action_checkbox'] +  list(self.list_display)
+        if not self.list_display_links:
+            for name in self.list_display:
+                if name != 'action_checkbox':
+                    self.list_display_links = [name]
+                    break
         super(ModelAdmin, self).__init__()
 
     def get_urls(self):
@@ -239,6 +253,8 @@ class ModelAdmin(BaseModelAdmin):
         from django.conf import settings
 
         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
+        if self.actions:
+            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
         if self.prepopulated_fields:
             js.append('js/urlify.js')
         if self.opts.get_ordered_objects():
@@ -390,6 +406,121 @@ class ModelAdmin(BaseModelAdmin):
             action_flag     = DELETION
         )
 
+    def action_checkbox(self, obj):
+        """
+        A list_display column containing a checkbox widget.
+        """
+        return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
+    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
+    action_checkbox.allow_tags = True
+
+    def get_actions(self, request=None):
+        """
+        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)
+        return actions
+
+    def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH):
+        """
+        Return a list of choices for use in a form object.  Each choice is a
+        tuple (name, description).
+        """
+        choices = [] + default_choices
+        for func, name, description in self.get_actions(request).itervalues():
+            choice = (name, description % model_format_dict(self.opts))
+            choices.append(choice)
+        return choices
+
+    def get_action(self, action):
+        """
+        Return a given action from a parameter, which can either be a calable,
+        or the name of a method on the ModelAdmin.  Return is a tuple of
+        (callable, name, description).
+        """
+        if callable(action):
+            func = action
+            action = action.__name__
+        elif hasattr(self, action):
+            func = getattr(self, action)
+        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 %d %s.") % (
+                    n, 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):
         """
@@ -529,6 +660,48 @@ class ModelAdmin(BaseModelAdmin):
             self.message_user(request, msg)
             return HttpResponseRedirect("../")
 
+    def response_action(self, request, queryset):
+        """
+        Handle an admin action. This is called if a request is POSTed to the
+        changelist; it returns an HttpResponse if the action was handled, and
+        None otherwise.
+        """
+        # There can be multiple action forms on the page (at the top
+        # and bottom of the change list, for example). Get the action
+        # whose button was pushed.
+        try:
+            action_index = int(request.POST.get('index', 0))
+        except ValueError:
+            action_index = 0
+
+        # Construct the action form.
+        data = request.POST.copy()
+        data.pop(helpers.ACTION_CHECKBOX_NAME, None)
+        data.pop("index", None)
+        action_form = self.action_form(data, auto_id=None)
+        action_form.fields['action'].choices = self.get_action_choices(request)
+
+        # If the form's valid we can handle the action.
+        if action_form.is_valid():
+            action = action_form.cleaned_data['action']
+            func, name, description = self.get_actions(request)[action]
+            
+            # Get the list of selected PKs. If nothing's selected, we can't 
+            # perform an action on it, so bail.
+            selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+            if not selected:
+                return None
+
+            response = func(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
+            # citizen and redirect back to the changelist page.
+            if isinstance(response, HttpResponse):
+                return response
+            else:
+                return HttpResponseRedirect(".")
+
     def add_view(self, request, form_url='', extra_context=None):
         "The 'add' admin view for this model."
         model = self.model
@@ -721,6 +894,14 @@ class ModelAdmin(BaseModelAdmin):
                 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':
+            response = self.response_action(request, queryset=cl.get_query_set())
+            if response:
+                return response
+                
         # If we're allowing changelist editing, we need to construct a formset
         # for the changelist given all the fields to be edited. Then we'll
         # use the formset to validate/process POSTed data.
@@ -764,7 +945,11 @@ class ModelAdmin(BaseModelAdmin):
         if formset:
             media = self.media + formset.media
         else:
-            media = None
+            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)        
 
         context = {
             'title': cl.title,
@@ -774,6 +959,9 @@ class ModelAdmin(BaseModelAdmin):
             'has_add_permission': self.has_add_permission(request),
             'root_path': self.admin_site.root_path,
             'app_label': app_label,
+            'action_form': action_form,
+            'actions_on_top': self.actions_on_top,
+            'actions_on_bottom': self.actions_on_bottom,
         }
         context.update(extra_context or {})
         return render_to_response(self.change_list_template or [

+ 66 - 59
django/contrib/admin/sites.py

@@ -28,11 +28,11 @@ class AdminSite(object):
     register() method, and the root() method can then be used as a Django view function
     that presents a full admin interface for the collection of registered models.
     """
-    
+
     index_template = None
     login_template = None
     app_index_template = None
-    
+
     def __init__(self, name=None):
         self._registry = {} # model_class class -> admin_class instance
         # TODO Root path is used to calculate urls under the old root() method
@@ -44,17 +44,19 @@ class AdminSite(object):
         else:
             name += '_'
         self.name = name
-    
+
+        self.actions = []
+
     def register(self, model_or_iterable, admin_class=None, **options):
         """
         Registers the given model(s) with the given admin class.
-        
+
         The model(s) should be Model classes, not instances.
-        
+
         If an admin class isn't given, it will use ModelAdmin (the default
         admin options). If keyword arguments are given -- e.g., list_display --
         they'll be applied as options to the admin class.
-        
+
         If a model is already registered, this will raise AlreadyRegistered.
         """
         if not admin_class:
@@ -65,13 +67,13 @@ class AdminSite(object):
             from django.contrib.admin.validation import validate
         else:
             validate = lambda model, adminclass: None
-        
+
         if isinstance(model_or_iterable, ModelBase):
             model_or_iterable = [model_or_iterable]
         for model in model_or_iterable:
             if model in self._registry:
                 raise AlreadyRegistered('The model %s is already registered' % model.__name__)
-            
+
             # If we got **options then dynamically construct a subclass of
             # admin_class with those **options.
             if options:
@@ -80,17 +82,17 @@ class AdminSite(object):
                 # which causes issues later on.
                 options['__module__'] = __name__
                 admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
-            
+
             # Validate (which might be a no-op)
             validate(admin_class, model)
-            
+
             # Instantiate the admin class to save in the registry
             self._registry[model] = admin_class(model, self)
-    
+
     def unregister(self, model_or_iterable):
         """
         Unregisters the given model(s).
-        
+
         If a model isn't already registered, this will raise NotRegistered.
         """
         if isinstance(model_or_iterable, ModelBase):
@@ -99,44 +101,49 @@ class AdminSite(object):
             if model not in self._registry:
                 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 has_permission(self, request):
         """
         Returns True if the given HttpRequest has permission to view
         *at least one* page in the admin site.
         """
         return request.user.is_authenticated() and request.user.is_staff
-    
+
     def check_dependencies(self):
         """
         Check that all things needed to run the admin have been correctly installed.
-        
+
         The default implementation checks that LogEntry, ContentType and the
         auth context processor are installed.
         """
         from django.contrib.admin.models import LogEntry
         from django.contrib.contenttypes.models import ContentType
-        
+
         if not LogEntry._meta.installed:
             raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.")
         if not ContentType._meta.installed:
             raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in your INSTALLED_APPS setting in order to use the admin application.")
         if 'django.core.context_processors.auth' not in settings.TEMPLATE_CONTEXT_PROCESSORS:
             raise ImproperlyConfigured("Put 'django.core.context_processors.auth' in your TEMPLATE_CONTEXT_PROCESSORS setting in order to use the admin application.")
-        
+
     def admin_view(self, view):
         """
         Decorator to create an "admin view attached to this ``AdminSite``. This
         wraps the view and provides permission checking by calling
         ``self.has_permission``.
-        
+
         You'll want to use this from within ``AdminSite.get_urls()``:
-            
+
             class MyAdminSite(AdminSite):
-                
+
                 def get_urls(self):
                     from django.conf.urls.defaults import patterns, url
-                    
+
                     urls = super(MyAdminSite, self).get_urls()
                     urls += patterns('',
                         url(r'^my_view/$', self.protected_view(some_view))
@@ -148,15 +155,15 @@ class AdminSite(object):
                 return self.login(request)
             return view(request, *args, **kwargs)
         return update_wrapper(inner, view)
-    
+
     def get_urls(self):
         from django.conf.urls.defaults import patterns, url, include
-        
+
         def wrap(view):
             def wrapper(*args, **kwargs):
                 return self.admin_view(view)(*args, **kwargs)
             return update_wrapper(wrapper, view)
-        
+
         # Admin-site-wide views.
         urlpatterns = patterns('',
             url(r'^$',
@@ -180,7 +187,7 @@ class AdminSite(object):
                 wrap(self.app_index),
                 name='%sadmin_app_list' % self.name),
         )
-        
+
         # Add in each model's views.
         for model, model_admin in self._registry.iteritems():
             urlpatterns += patterns('',
@@ -188,11 +195,11 @@ class AdminSite(object):
                     include(model_admin.urls))
             )
         return urlpatterns
-    
+
     def urls(self):
         return self.get_urls()
     urls = property(urls)
-        
+
     def password_change(self, request):
         """
         Handles the "change password" task -- both form display and validation.
@@ -200,18 +207,18 @@ class AdminSite(object):
         from django.contrib.auth.views import password_change
         return password_change(request,
             post_change_redirect='%spassword_change/done/' % self.root_path)
-    
+
     def password_change_done(self, request):
         """
         Displays the "success" page after a password change.
         """
         from django.contrib.auth.views import password_change_done
         return password_change_done(request)
-    
+
     def i18n_javascript(self, request):
         """
         Displays the i18n JavaScript that the Django admin requires.
-        
+
         This takes into account the USE_I18N setting. If it's set to False, the
         generated JavaScript will be leaner and faster.
         """
@@ -220,23 +227,23 @@ class AdminSite(object):
         else:
             from django.views.i18n import null_javascript_catalog as javascript_catalog
         return javascript_catalog(request, packages='django.conf')
-    
+
     def logout(self, request):
         """
         Logs out the user for the given HttpRequest.
-        
+
         This should *not* assume the user is already logged in.
         """
         from django.contrib.auth.views import logout
         return logout(request)
     logout = never_cache(logout)
-    
+
     def login(self, request):
         """
         Displays the login form for the given HttpRequest.
         """
         from django.contrib.auth.models import User
-        
+
         # If this isn't already the login page, display it.
         if not request.POST.has_key(LOGIN_FORM_KEY):
             if request.POST:
@@ -244,14 +251,14 @@ class AdminSite(object):
             else:
                 message = ""
             return self.display_login_form(request, message)
-        
+
         # Check that the user accepts cookies.
         if not request.session.test_cookie_worked():
             message = _("Looks like your browser isn't configured to accept cookies. Please enable cookies, reload this page, and try again.")
             return self.display_login_form(request, message)
         else:
             request.session.delete_test_cookie()
-        
+
         # Check the password.
         username = request.POST.get('username', None)
         password = request.POST.get('password', None)
@@ -271,7 +278,7 @@ class AdminSite(object):
                     else:
                         message = _("Usernames cannot contain the '@' character.")
             return self.display_login_form(request, message)
-        
+
         # The user data is correct; log in the user in and continue.
         else:
             if user.is_active and user.is_staff:
@@ -280,7 +287,7 @@ class AdminSite(object):
             else:
                 return self.display_login_form(request, ERROR_MESSAGE)
     login = never_cache(login)
-    
+
     def index(self, request, extra_context=None):
         """
         Displays the main admin index page, which lists all of the installed
@@ -291,14 +298,14 @@ class AdminSite(object):
         for model, model_admin in self._registry.items():
             app_label = model._meta.app_label
             has_module_perms = user.has_module_perms(app_label)
-            
+
             if has_module_perms:
                 perms = {
                     'add': model_admin.has_add_permission(request),
                     'change': model_admin.has_change_permission(request),
                     'delete': model_admin.has_delete_permission(request),
                 }
-                
+
                 # Check whether user has any perm for this module.
                 # If so, add the module to the model_list.
                 if True in perms.values():
@@ -316,15 +323,15 @@ class AdminSite(object):
                             'has_module_perms': has_module_perms,
                             'models': [model_dict],
                         }
-        
+
         # Sort the apps alphabetically.
         app_list = app_dict.values()
         app_list.sort(lambda x, y: cmp(x['name'], y['name']))
-        
+
         # Sort the models alphabetically within each app.
         for app in app_list:
             app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
-        
+
         context = {
             'title': _('Site administration'),
             'app_list': app_list,
@@ -335,7 +342,7 @@ class AdminSite(object):
             context_instance=template.RequestContext(request)
         )
     index = never_cache(index)
-    
+
     def display_login_form(self, request, error_message='', extra_context=None):
         request.session.set_test_cookie()
         context = {
@@ -348,7 +355,7 @@ class AdminSite(object):
         return render_to_response(self.login_template or 'admin/login.html', context,
             context_instance=template.RequestContext(request)
         )
-    
+
     def app_index(self, request, app_label, extra_context=None):
         user = request.user
         has_module_perms = user.has_module_perms(app_label)
@@ -394,46 +401,46 @@ class AdminSite(object):
         return render_to_response(self.app_index_template or 'admin/app_index.html', context,
             context_instance=template.RequestContext(request)
         )
-        
+
     def root(self, request, url):
         """
         DEPRECATED. This function is the old way of handling URL resolution, and
         is deprecated in favor of real URL resolution -- see ``get_urls()``.
-        
+
         This function still exists for backwards-compatibility; it will be
         removed in Django 1.3.
         """
         import warnings
         warnings.warn(
-            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.", 
+            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
             PendingDeprecationWarning
         )
-        
+
         #
         # Again, remember that the following only exists for
         # backwards-compatibility. Any new URLs, changes to existing URLs, or
         # whatever need to be done up in get_urls(), above!
         #
-        
+
         if request.method == 'GET' and not request.path.endswith('/'):
             return http.HttpResponseRedirect(request.path + '/')
-        
+
         if settings.DEBUG:
             self.check_dependencies()
-        
+
         # Figure out the admin base URL path and stash it for later use
         self.root_path = re.sub(re.escape(url) + '$', '', request.path)
-        
+
         url = url.rstrip('/') # Trim trailing slash, if it exists.
-        
+
         # The 'logout' view doesn't require that the person is logged in.
         if url == 'logout':
             return self.logout(request)
-        
+
         # Check permission to continue or display login form.
         if not self.has_permission(request):
             return self.login(request)
-        
+
         if url == '':
             return self.index(request)
         elif url == 'password_change':
@@ -451,9 +458,9 @@ class AdminSite(object):
                 return self.model_page(request, *url.split('/', 2))
             else:
                 return self.app_index(request, url)
-        
+
         raise http.Http404('The requested admin page does not exist.')
-        
+
     def model_page(self, request, app_label, model_name, rest_of_url=None):
         """
         DEPRECATED. This is the old way of handling a model view on the admin
@@ -468,7 +475,7 @@ class AdminSite(object):
         except KeyError:
             raise http.Http404("This model exists but has not been registered with the admin site.")
         return admin_obj(request, rest_of_url)
-    model_page = never_cache(model_page)    
+    model_page = never_cache(model_page)
 
 # This global object represents the default admin site, for the common case.
 # You can instantiate AdminSite in your own code to create a custom admin site.

+ 5 - 0
django/contrib/admin/templates/admin/actions.html

@@ -0,0 +1,5 @@
+{% load i18n %}
+<div class="actions">
+    {% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
+    <button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
+</div>

+ 8 - 4
django/contrib/admin/templates/admin/change_list.html

@@ -7,8 +7,8 @@
   {% if cl.formset %}
     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
     <script type="text/javascript" src="../../jsi18n/"></script>
-    {{ media }}
   {% endif %}
+  {{ media }}
 {% endblock %}
 
 {% block bodyclass %}change-list{% endblock %}
@@ -63,14 +63,18 @@
         {% endif %}
       {% endblock %}
       
+      <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
       {% if cl.formset %}
-        <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
         {{ cl.formset.management_form }}
       {% endif %}
 
-      {% block result_list %}{% result_list cl %}{% endblock %}
+      {% block result_list %}
+          {% if 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 %}
+      {% endblock %}
       {% block pagination %}{% pagination cl %}{% endblock %}
-      {% if cl.formset %}</form>{% endif %}
+      </form>
     </div>
   </div>
 {% endblock %}

+ 37 - 0
django/contrib/admin/templates/admin/delete_selected_confirmation.html

@@ -0,0 +1,37 @@
+{% extends "admin/base_site.html" %}
+{% load i18n %}
+
+{% block breadcrumbs %}
+<div class="breadcrumbs">
+     <a href="../../">{% trans "Home" %}</a> &rsaquo;
+     <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
+     <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
+     {% trans 'Delete multiple objects' %}
+</div>
+{% endblock %}
+
+{% block content %}
+{% if perms_lacking %}
+    <p>{% blocktrans %}Deleting the {{ object_name }} would result in deleting related objects, but your account doesn't have permission to delete the following types of objects:{% endblocktrans %}</p>
+    <ul>
+    {% for obj in perms_lacking %}
+        <li>{{ obj }}</li>
+    {% endfor %}
+    </ul>
+{% else %}
+    <p>{% blocktrans %}Are you sure you want to delete the selected {{ object_name }} objects? All of the following objects and it's related items will be deleted:{% endblocktrans %}</p>
+    {% for deleteable_object in deletable_objects %}
+        <ul>{{ deleteable_object|unordered_list }}</ul>
+    {% endfor %}
+    <form action="" method="post">
+    <div>
+    {% for obj in queryset %}
+    <input type="hidden" name="{{ action_checkbox_name }}" value="{{ obj.pk }}" />
+    {% endfor %}
+    <input type="hidden" name="action" value="delete_selected" />
+    <input type="hidden" name="post" value="yes" />
+    <input type="submit" value="{% trans "Yes, I'm sure" %}" />
+    </div>
+    </form>
+{% endif %}
+{% endblock %}

+ 9 - 0
django/contrib/admin/templatetags/admin_list.py

@@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
 def admin_list_filter(cl, spec):
     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
 admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
+
+def admin_actions(context):
+    """
+    Track the number of times the action field has been rendered on the page,
+    so we know which value to use.
+    """
+    context['action_index'] = context.get('action_index', -1) + 1
+    return context
+admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)

+ 78 - 13
django/contrib/admin/util.py

@@ -4,7 +4,8 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
 from django.utils.encoding import force_unicode
-from django.utils.translation import ugettext as _
+from django.utils.translation import ungettext, ugettext as _
+from django.core.urlresolvers import reverse, NoReverseMatch
 
 def quote(s):
     """
@@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
         current = current[-1]
     current.append(val)
 
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
-    "Helper function that recursively populates deleted_objects."
+def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
+    """
+    Returns the url to the admin change view for the given app_label,
+    module_name and primary key.
+    """
+    try:
+        return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
+    except NoReverseMatch:
+        return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
+
+def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
+    """
+    Helper function that recursively populates deleted_objects.
+
+    `levels_to_root` defines the number of directories (../) to reach the
+    admin root path. In a change_view this is 4, in a change_list view 2.
+
+    This is for backwards compatibility since the options.delete_selected
+    method uses this function also from a change_list view.
+    This will not be used if we can reverse the URL.
+    """
     nh = _nest_help # Bind to local variable for performance
     if current_depth > 16:
         return # Avoid recursing too deep.
@@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                     # Display a link to the admin page.
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
                         (escape(capfirst(related.opts.verbose_name)),
-                        related.opts.app_label,
-                        related.opts.object_name.lower(),
-                        sub_obj._get_pk_val(),
+                        get_change_view_url(related.opts.app_label,
+                                            related.opts.object_name.lower(),
+                                            sub_obj._get_pk_val(),
+                                            admin_site,
+                                            levels_to_root),
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
         else:
@@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                     # Display a link to the admin page.
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % 
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
                         (escape(capfirst(related.opts.verbose_name)),
-                        related.opts.app_label,
-                        related.opts.object_name.lower(),
-                        sub_obj._get_pk_val(),
+                        get_change_view_url(related.opts.app_label,
+                                            related.opts.object_name.lower(),
+                                            sub_obj._get_pk_val(),
+                                            admin_site,
+                                            levels_to_root),
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
             # If there were related objects, and the user doesn't have
@@ -147,11 +171,52 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                     # Display a link to the admin page.
                     nh(deleted_objects, current_depth, [
                         mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
-                        (u' <a href="../../../../%s/%s/%s/">%s</a>' % \
-                            (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
+                        (u' <a href="%s">%s</a>' % \
+                            (get_change_view_url(related.opts.app_label,
+                                                 related.opts.object_name.lower(),
+                                                 sub_obj._get_pk_val(),
+                                                 admin_site,
+                                                 levels_to_root),
+                            escape(sub_obj)))), []])
         # If there were related objects, and the user doesn't have
         # permission to change them, add the missing perm to perms_needed.
         if has_admin and has_related_objs:
             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
             if not user.has_perm(p):
                 perms_needed.add(related.opts.verbose_name)
+
+def model_format_dict(obj):
+    """
+    Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
+    typically for use with string formatting.
+
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+
+    """
+    if isinstance(obj, (models.Model, models.base.ModelBase)):
+        opts = obj._meta
+    elif isinstance(obj, models.query.QuerySet):
+        opts = obj.model._meta
+    else:
+        opts = obj
+    return {
+        'verbose_name': force_unicode(opts.verbose_name),
+        'verbose_name_plural': force_unicode(opts.verbose_name_plural)
+    }
+
+def model_ngettext(obj, n=None):
+    """
+    Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
+    depending on the count `n`.
+
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+    If `obj` is a `QuerySet` instance, `n` is optional and the length of the
+    `QuerySet` is used.
+
+    """
+    if isinstance(obj, models.query.QuerySet):
+        if n is None:
+            n = obj.count()
+        obj = obj.model
+    d = model_format_dict(obj)
+    return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)

+ 12 - 3
django/contrib/admin/validation.py

@@ -63,7 +63,7 @@ def validate(cls, model):
     if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
         raise ImproperlyConfigured("'%s.list_per_page' should be a integer."
                 % cls.__name__)
-    
+
     # list_editable
     if hasattr(cls, 'list_editable') and cls.list_editable:
         check_isseq(cls, 'list_editable', cls.list_editable)
@@ -76,7 +76,7 @@ def validate(cls, model):
                 field = opts.get_field_by_name(field_name)[0]
             except models.FieldDoesNotExist:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
-                    "field, '%s', not defiend on %s." 
+                    "field, '%s', not defiend on %s."
                     % (cls.__name__, idx, field_name, model.__name__))
             if field_name not in cls.list_display:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to "
@@ -89,7 +89,7 @@ def validate(cls, model):
             if not cls.list_display_links and cls.list_display[0] in cls.list_editable:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to"
                     " the first field in list_display, '%s', which can't be"
-                    " used unless list_display_links is set." 
+                    " used unless list_display_links is set."
                     % (cls.__name__, idx, cls.list_display[0]))
             if not field.editable:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
@@ -127,6 +127,14 @@ 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
@@ -135,6 +143,7 @@ def validate(cls, model):
             raise ImproperlyConfigured("'%s.%s' should be a boolean."
                     % (cls.__name__, attr))
 
+
     # inlines = []
     if hasattr(cls, 'inlines'):
         check_isseq(cls, 'inlines', cls.inlines)

+ 1 - 1
docs/index.txt

@@ -78,7 +78,7 @@ The development process
 Other batteries included
 ========================
 
-    * :ref:`Admin site <ref-contrib-admin>`
+    * :ref:`Admin site <ref-contrib-admin>` | :ref:`Admin actions <ref-contrib-admin-actions>`
     * :ref:`Authentication <topics-auth>`
     * :ref:`Cache system <topics-cache>`
     * :ref:`Conditional content processing <topics-conditional-processing>`

BIN
docs/ref/contrib/admin/_images/article_actions.png


BIN
docs/ref/contrib/admin/_images/article_actions_message.png


+ 0 - 0
docs/ref/contrib/_images/flatfiles_admin.png → docs/ref/contrib/admin/_images/flatfiles_admin.png


BIN
docs/ref/contrib/admin/_images/user_actions.png


+ 0 - 0
docs/ref/contrib/_images/users_changelist.png → docs/ref/contrib/admin/_images/users_changelist.png


+ 239 - 0
docs/ref/contrib/admin/actions.txt

@@ -0,0 +1,239 @@
+.. _ref-contrib-admin-actions:
+
+=============
+Admin actions
+=============
+
+.. versionadded:: 1.1
+
+.. currentmodule:: django.contrib.admin
+
+The basic workflow of Django's admin is, in a nutshell, "select an object,
+then change it." This works well for a majority of use cases. However, if you
+need to make the same change to many objects at once, this workflow can be
+quite tedious.
+
+In these cases, Django's admin lets you write and register "actions" -- simple
+functions that get called with a list of objects selected on the change list
+page.
+
+If you look at any change list in the admin, you'll see this feature in
+action; Django ships with a "delete selected objects" action available to all
+models. For example, here's the user module from Django's built-in
+:mod:`django.contrib.auth` app:
+
+.. image:: _images/user_actions.png
+    
+Read on to find out how to add your own actions to this list.
+
+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::
+
+    from django.db import models
+
+    STATUS_CHOICES = (
+        ('d', 'Draft'),
+        ('p', 'Published'),
+        ('w', 'Withdrawn'),
+    )
+
+    class Article(models.Model):
+        title = models.CharField(max_length=100)
+        body = models.TextField()
+        status = models.CharField(max_length=1, choices=STATUS_CHOICES)
+    
+        def __unicode__(self):
+            return self.title
+        
+A common task we might perform with a model like this is to update an
+article's status from "draft" to "published". We could easily do this in the
+admin one article at a time, but if we wanted to bulk-publish a group of
+articles, it'd be tedious. So, let's write an action that lets us change an
+article's status to "published."
+
+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::
+
+    def make_published(request, queryset):
+        queryset.update(status='p')
+        
+.. note::
+
+    For the best performance, we're using the queryset's :ref:`update method
+    <topics-db-queries-update>`. Other types of actions might need to deal
+    with each object individually; in these cases we'd just iterate over the
+    queryset::
+    
+        for obj in queryset:
+            do_something_with(obj)
+            
+That's actually all there is to writing an action! However, we'll take one
+more optional-but-useful step and give the action a "nice" title in the admin.
+By default, this action would appear in the action list as "Make published" --
+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):
+        queryset.update(status='p')
+    make_published.short_description = "Mark selected stories as published"
+    
+.. note::
+
+    This might look familiar; the admin's ``list_display`` option uses the
+    same technique to provide human-readable descriptions for callback
+    functions registered there, too.
+    
+Adding actions to the :class:`ModelAdmin`
+-----------------------------------------
+
+Next, we'll need to inform our :class:`ModelAdmin` of the action. This works
+just like any other configuration option. So, the complete ``admin.py`` with
+the action and its registration would look like::
+
+    from django.contrib import admin
+    from myapp.models import Article
+
+    def make_published(request, queryset):
+        queryset.update(status='p')
+    make_published.short_description = "Mark selected stories as published"
+
+    class ArticleAdmin(admin.ModelAdmin):
+        list_display = ['title', 'status']
+        ordering = ['title']
+        actions = [make_published]
+
+    admin.site.register(Article, ArticleAdmin)
+    
+That code will give us an admin change list that looks something like this:
+
+.. image:: _images/article_actions.png
+    
+That's really all there is to it! If you're itching to write your own actions,
+you now know enough to get started. The rest of this document just covers more
+advanced techniques.
+
+Advanced action techniques
+==========================
+
+There's a couple of extra options and possibilities you can exploit for more
+advanced options.
+
+Actions as :class:`ModelAdmin` methods
+--------------------------------------
+
+The example above shows the ``make_published`` action defined as a simple
+function. That's perfectly fine, but it's not perfect from a code design point
+of view: since the action is tightly coupled to the ``Article`` object, it
+makes sense to hook the action to the ``ArticleAdmin`` object itself.
+
+That's easy enough to do::
+
+    class ArticleAdmin(admin.ModelAdmin):
+        ...
+        
+        actions = ['make_published']
+
+        def make_published(self, request, queryset):
+            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.
+
+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.
+
+For example, we can use ``self`` to flash a message to the user informing her
+that the action was successful::
+
+    class ArticleAdmin(admin.ModelAdmin):
+        ...
+
+        def make_published(self, request, queryset):
+            rows_updated = queryset.update(status='p')
+            if rows_updated == 1:
+                message_bit = "1 story was"
+            else:
+                message_bit = "%s stories were" % rows_updated
+            self.message_user(request, "%s successfully marked as published." % message_bit)
+
+This make the action match what the admin itself does after successfully
+performing an action:
+
+.. image:: _images/article_actions_message.png
+    
+Actions that provide intermediate pages
+---------------------------------------
+
+By default, after an action is performed the user is simply redirected back
+the the original change list page. However, some actions, especially more
+complex ones, will need to return intermediate pages. For example, the
+built-in delete action asks for confirmation before deleting the selected
+objects.
+
+To provide an intermediary page, simply return an
+:class:`~django.http.HttpResponse` (or subclass) from your action. For
+example, you might write a simple export function that uses Django's
+:ref:`serialization functions <topics-serialization>` to dump some selected
+objects as JSON::
+
+    from django.http import HttpResponse
+    from django.core import serializers
+
+    def export_as_json(request, queryset):
+        response = HttpResponse(mimetype="text/javascript")
+        serialize.serialize(queryset, stream=response)
+        return response
+
+Generally, something like the above isn't considered a great idea. Most of the
+time, the best practice will be to return an
+:class:`~django.http.HttpResponseRedirect` and redirect the user to a view
+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::
+
+    from django.contrib import admin
+    from django.contrib.contenttypes.models import ContentType
+    from django.http import HttpResponseRedirect
+    
+    def export_selected_objects(request, queryset):
+        selected = request.POST.getlist(admin.ACTION_CHECKBOX_NAME)
+        ct = ContentType.objects.get_for_model(queryset.model)
+        return HttpResponseRedirect("/export/?ct=%s&ids=%s" % (ct.pk, ",".join(selected)))
+
+As you can see, the action is the simple part; all the complex logic would
+belong in your export view. This would need to deal with objects of any type,
+hence the business with the ``ContentType``.
+
+Writing this view is left as an exercise to the reader.
+
+Making actions available globally
+---------------------------------
+
+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()`::
+
+    from django.contrib import admin
+        
+    admin.site.add_action(export_selected_objects)
+    

+ 21 - 2
docs/ref/contrib/admin.txt → docs/ref/contrib/admin/index.txt

@@ -38,6 +38,14 @@ There are five steps in activating the Django admin site:
        ``ModelAdmin`` classes.
 
     5. Hook the ``AdminSite`` instance into your URLconf.
+    
+Other topics
+------------
+
+.. toctree::
+   :maxdepth: 1
+   
+   actions
 
 ``ModelAdmin`` objects
 ======================
@@ -664,6 +672,19 @@ The value is another dictionary; these arguments will be passed to
     that have ``raw_id_fields`` or ``radio_fields`` set. That's because
     ``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
 
+``actions``
+~~~~~~~~~~~
+
+A list of actions to make available on the change list page. See
+:ref:`ref-contrib-admin-actions` for details.
+
+``actions_on_top``, ``actions_on_buttom``
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+
+Controls where on the page the actions bar appears. By default, the admin
+changelist displays actions at the top of the page (``actions_on_top = True;
+actions_on_bottom = False``).
+
 ``ModelAdmin`` methods
 ----------------------
 
@@ -1138,7 +1159,6 @@ or add anything you like. Then, simply create an instance of your
 Python class), and register your models and ``ModelAdmin`` subclasses
 with it instead of using the default.
 
-
 Hooking ``AdminSite`` instances into your URLconf
 -------------------------------------------------
 
@@ -1177,7 +1197,6 @@ There is really no need to use autodiscover when using your own ``AdminSite``
 instance since you will likely be importing all the per-app admin.py modules
 in your ``myproject.admin`` module.
 
-
 Multiple admin sites in the same URLconf
 ----------------------------------------
 

+ 1 - 1
docs/ref/contrib/index.txt

@@ -24,7 +24,7 @@ those packages have.
 .. toctree::
    :maxdepth: 1
 
-   admin
+   admin/index
    auth
    comments/index
    contenttypes

+ 1 - 1
tests/regressiontests/admin_registration/models.py

@@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
 >>> site._registry[Person].search_fields
 ['name']
 >>> site._registry[Person].list_display
-['__str__']
+['action_checkbox', '__str__']
 >>> site._registry[Person].save_on_top
 True
 

+ 15 - 0
tests/regressiontests/admin_views/fixtures/admin-views-actions.xml

@@ -0,0 +1,15 @@
+<?xml version="1.0" encoding="utf-8"?>
+<django-objects version="1.0">
+    <object pk="1" model="admin_views.subscriber">
+        <field type="CharField" name="name">John Doe</field>
+        <field type="CharField" name="email">john@example.org</field>
+    </object>
+    <object pk="2" model="admin_views.subscriber">
+        <field type="CharField" name="name">Max Mustermann</field>
+        <field type="CharField" name="email">max@example.org</field>
+    </object>
+    <object pk="1" model="admin_views.externalsubscriber">
+        <field type="CharField" name="name">John Doe</field>
+        <field type="CharField" name="email">john@example.org</field>
+    </object>
+</django-objects>

+ 38 - 0
tests/regressiontests/admin_views/models.py

@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from django.db import models
 from django.contrib import admin
+from django.core.mail import EmailMessage
 
 class Section(models.Model):
     """
@@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
         BarAccountAdmin
     )
 
+class Subscriber(models.Model):
+    name = models.CharField(blank=False, max_length=80)
+    email = models.EmailField(blank=False, max_length=175)
+
+    def __unicode__(self):
+        return "%s (%s)" % (self.name, self.email)
+
+class SubscriberAdmin(admin.ModelAdmin):
+    actions = ['delete_selected', 'mail_admin']
+
+    def mail_admin(self, request, selected):
+        EmailMessage(
+            'Greetings from a ModelAdmin action',
+            'This is the test email from a admin action',
+            'from@example.com',
+            ['to@example.com']
+        ).send()
+
+class ExternalSubscriber(Subscriber):
+    pass
+
+def external_mail(request, selected):
+    EmailMessage(
+        'Greetings from a function action',
+        'This is the test email from a function action',
+        'from@example.com',
+        ['to@example.com']
+    ).send()
+
+def redirect_to(request, selected):
+    from django.http import HttpResponseRedirect
+    return HttpResponseRedirect('/some-where-else/')
+
+class ExternalSubscriberAdmin(admin.ModelAdmin):
+    actions = [external_mail, redirect_to]
 
 admin.site.register(Article, ArticleAdmin)
 admin.site.register(CustomArticle, CustomArticleAdmin)
@@ -208,6 +244,8 @@ admin.site.register(Color)
 admin.site.register(Thing, ThingAdmin)
 admin.site.register(Person, PersonAdmin)
 admin.site.register(Persona, PersonaAdmin)
+admin.site.register(Subscriber, SubscriberAdmin)
+admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
 
 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
 # That way we cover all four cases:

+ 80 - 16
tests/regressiontests/admin_views/tests.py

@@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.admin.models import LogEntry
 from django.contrib.admin.sites import LOGIN_FORM_KEY
 from django.contrib.admin.util import quote
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.utils.html import escape
 
 # local test models
-from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, ExternalSubscriber
 
 try:
     set
@@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
     def test_changelist_to_changeform_link(self):
         "The link from the changelist referring to the changeform of the object should be quoted"
         response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/')
-        should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
+        should_contain = """<th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
         self.assertContains(response, should_contain)
 
     def test_recentactions_link(self):
@@ -738,29 +739,30 @@ class AdminViewListEditable(TestCase):
 
     def tearDown(self):
         self.client.logout()
-    
+
     def test_changelist_input_html(self):
         response = self.client.get('/test_admin/admin/admin_views/person/')
         # 2 inputs per object(the field and the hidden id field) = 6
         # 2 management hidden fields = 2
+        # 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
         # main form submit button = 1
         # search field and search submit button = 2
         # 6 + 2 + 1 + 2 = 11 inputs
-        self.failUnlessEqual(response.content.count("<input"), 11)
+        self.failUnlessEqual(response.content.count("<input"), 15)
         # 1 select per object = 3 selects
-        self.failUnlessEqual(response.content.count("<select"), 3)
-    
+        self.failUnlessEqual(response.content.count("<select"), 4)
+
     def test_post_submission(self):
         data = {
             "form-TOTAL_FORMS": "3",
             "form-INITIAL_FORMS": "3",
-            
+
             "form-0-gender": "1",
             "form-0-id": "1",
-            
+
             "form-1-gender": "2",
             "form-1-id": "2",
-            
+
             "form-2-alive": "checked",
             "form-2-gender": "1",
             "form-2-id": "3",
@@ -769,34 +771,34 @@ class AdminViewListEditable(TestCase):
 
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
         self.failUnlessEqual(Person.objects.get(name="Grace Hooper").gender, 2)
-        
+
         # test a filtered page
         data = {
             "form-TOTAL_FORMS": "2",
             "form-INITIAL_FORMS": "2",
-            
+
             "form-0-id": "1",
             "form-0-gender": "1",
             "form-0-alive": "checked",
-            
+
             "form-1-id": "3",
             "form-1-gender": "1",
             "form-1-alive": "checked",
         }
         self.client.post('/test_admin/admin/admin_views/person/?gender__exact=1', data)
-        
+
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, True)
-        
+
         # test a searched page
         data = {
             "form-TOTAL_FORMS": "1",
             "form-INITIAL_FORMS": "1",
-            
+
             "form-0-id": "1",
             "form-0-gender": "1"
         }
         self.client.post('/test_admin/admin/admin_views/person/?q=mauchly', data)
-        
+
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
 
 class AdminInheritedInlinesTest(TestCase):
@@ -875,3 +877,65 @@ class AdminInheritedInlinesTest(TestCase):
         self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
         self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
         self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
+
+from django.core import mail
+
+class AdminActionsTest(TestCase):
+    fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
+
+    def setUp(self):
+        self.client.login(username='super', password='secret')
+
+    def tearDown(self):
+        self.client.logout()
+
+    def test_model_admin_custom_action(self):
+        "Tests a custom action defined in a ModelAdmin method"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1],
+            'action' : 'mail_admin',
+            'index': 0,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+        self.assertEquals(len(mail.outbox), 1)
+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
+
+    def test_model_admin_default_delete_action(self):
+        "Tests the default delete action defined as a ModelAdmin method"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1, 2],
+            'action' : 'delete_selected',
+            'index': 0,
+        }
+        delete_confirmation_data = {
+            ACTION_CHECKBOX_NAME: [1, 2],
+            'action' : 'delete_selected',
+            'index': 0,
+            'post': 'yes',
+        }
+        confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+        self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
+        self.failUnless(confirmation.content.count(ACTION_CHECKBOX_NAME) == 2)
+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
+        self.failUnlessEqual(Subscriber.objects.count(), 0)
+
+    def test_custom_function_mail_action(self):
+        "Tests a custom action defined in a function"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1],
+            'action' : 'external_mail',
+            'index': 0,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
+        self.assertEquals(len(mail.outbox), 1)
+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
+
+    def test_custom_function_action_with_redirect(self):
+        "Tests a custom action defined in a function"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1],
+            'action' : 'redirect_to',
+            'index': 0,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
+        self.failUnlessEqual(response.status_code, 302)