Forráskód Böngészése

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 éve
szülő
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/>
     Ned Batchelder <http://www.nedbatchelder.com/>
     batiste@dosimple.ch
     batiste@dosimple.ch
     Batman
     Batman
+    Brian Beck <http://blog.brianbeck.com/>
     Shannon -jj Behrens <http://jjinux.blogspot.com/>
     Shannon -jj Behrens <http://jjinux.blogspot.com/>
     Esdras Beleza <linux@esdrasbeleza.com>
     Esdras Beleza <linux@esdrasbeleza.com>
     Chris Bennett <chrisrbennett@yahoo.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>
     Daniel Lindsley <polarcowz@gmail.com>
     Trey Long <trey@ktrl.com>
     Trey Long <trey@ktrl.com>
     msaelices <msaelices@gmail.com>
     msaelices <msaelices@gmail.com>
+    Martin Mahner <http://www.mahner.org/>
     Matt McClanahan <http://mmcc.cx/>
     Matt McClanahan <http://mmcc.cx/>
     Frantisek Malina <vizualbod@vizualbod.com>
     Frantisek Malina <vizualbod@vizualbod.com>
     Martin Maney <http://www.chipy.org/Martin_Maney>
     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 ModelAdmin, HORIZONTAL, VERTICAL
 from django.contrib.admin.options import StackedInline, TabularInline
 from django.contrib.admin.options import StackedInline, TabularInline
 from django.contrib.admin.sites import AdminSite, site
 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.utils.encoding import force_unicode
 from django.contrib.admin.util import flatten_fieldsets
 from django.contrib.admin.util import flatten_fieldsets
 from django.contrib.contenttypes.models import ContentType
 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):
 class AdminForm(object):
     def __init__(self, form, fieldsets, prepopulated_fields):
     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.original.content_type_id = ContentType.objects.get_for_model(original).pk
         self.show_url = original and hasattr(original, 'get_absolute_url')
         self.show_url = original and hasattr(original, 'get_absolute_url')
         super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
         super(InlineAdminForm, self).__init__(form, fieldsets, prepopulated_fields)
-    
+
     def __iter__(self):
     def __iter__(self):
         for name, options in self.fieldsets:
         for name, options in self.fieldsets:
             yield InlineFieldset(self.formset, self.form, name, **options)
             yield InlineFieldset(self.formset, self.form, name, **options)
-    
+
     def field_count(self):
     def field_count(self):
         # tabular.html uses this function for colspan value.
         # tabular.html uses this function for colspan value.
         num_of_fields = 1 # always has at least one field
         num_of_fields = 1 # always has at least one field
@@ -149,7 +157,7 @@ class InlineAdminForm(AdminForm):
 
 
     def pk_field(self):
     def pk_field(self):
         return AdminField(self.form, self.formset._pk_field.name, False)
         return AdminField(self.form, self.formset._pk_field.name, False)
-    
+
     def fk_field(self):
     def fk_field(self):
         fk = getattr(self.formset, "fk", None)
         fk = getattr(self.formset, "fk", None)
         if fk:
         if fk:
@@ -169,14 +177,14 @@ class InlineFieldset(Fieldset):
     def __init__(self, formset, *args, **kwargs):
     def __init__(self, formset, *args, **kwargs):
         self.formset = formset
         self.formset = formset
         super(InlineFieldset, self).__init__(*args, **kwargs)
         super(InlineFieldset, self).__init__(*args, **kwargs)
-        
+
     def __iter__(self):
     def __iter__(self):
         fk = getattr(self.formset, "fk", None)
         fk = getattr(self.formset, "fk", None)
         for field in self.fields:
         for field in self.fields:
             if fk and fk.name == field:
             if fk and fk.name == field:
                 continue
                 continue
             yield Fieldline(self.form, field)
             yield Fieldline(self.form, field)
-            
+
 class AdminErrorList(forms.util.ErrorList):
 class AdminErrorList(forms.util.ErrorList):
     """
     """
     Stores all errors for the form/formsets in an add/change stage view.
     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 {
 #changelist table thead th {
     white-space: nowrap;
     white-space: nowrap;
+    vertical-align: middle;
+}
+
+#changelist table thead th:first-child {
+    width: 1.5em;
+    text-align: center;
 }
 }
 
 
 #changelist table tbody td {
 #changelist table tbody td {
     border-left: 1px solid #ddd;
     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 {
 #changelist table tfoot {
     color: #666;
     color: #666;
 }
 }
@@ -209,3 +221,35 @@
     border-color: #036;
     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.contenttypes.models import ContentType
 from django.contrib.admin import widgets
 from django.contrib.admin import widgets
 from django.contrib.admin import helpers
 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.core.exceptions import PermissionDenied
 from django.db import models, transaction
 from django.db import models, transaction
+from django.db.models.fields import BLANK_CHOICE_DASH
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, render_to_response
 from django.shortcuts import get_object_or_404, render_to_response
 from django.utils.functional import update_wrapper
 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.functional import curry
 from django.utils.text import capfirst, get_text_list
 from django.utils.text import capfirst, get_text_list
 from django.utils.translation import ugettext as _
 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
 from django.utils.encoding import force_unicode
 try:
 try:
     set
     set
@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
     delete_confirmation_template = None
     delete_confirmation_template = None
     object_history_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):
     def __init__(self, model, admin_site):
         self.model = model
         self.model = model
         self.opts = model._meta
         self.opts = model._meta
@@ -200,6 +207,13 @@ class ModelAdmin(BaseModelAdmin):
         for inline_class in self.inlines:
         for inline_class in self.inlines:
             inline_instance = inline_class(self.model, self.admin_site)
             inline_instance = inline_class(self.model, self.admin_site)
             self.inline_instances.append(inline_instance)
             self.inline_instances.append(inline_instance)
+        if 'action_checkbox' not in self.list_display:
+            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__()
         super(ModelAdmin, self).__init__()
 
 
     def get_urls(self):
     def get_urls(self):
@@ -239,6 +253,8 @@ class ModelAdmin(BaseModelAdmin):
         from django.conf import settings
         from django.conf import settings
 
 
         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
+        if self.actions:
+            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
         if self.prepopulated_fields:
         if self.prepopulated_fields:
             js.append('js/urlify.js')
             js.append('js/urlify.js')
         if self.opts.get_ordered_objects():
         if self.opts.get_ordered_objects():
@@ -390,6 +406,121 @@ class ModelAdmin(BaseModelAdmin):
             action_flag     = DELETION
             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):
     def construct_change_message(self, request, form, formsets):
         """
         """
@@ -529,6 +660,48 @@ class ModelAdmin(BaseModelAdmin):
             self.message_user(request, msg)
             self.message_user(request, msg)
             return HttpResponseRedirect("../")
             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):
     def add_view(self, request, form_url='', extra_context=None):
         "The 'add' admin view for this model."
         "The 'add' admin view for this model."
         model = self.model
         model = self.model
@@ -721,6 +894,14 @@ class ModelAdmin(BaseModelAdmin):
                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
 
 
+        # If the request was POSTed, this might be a bulk action or a bulk edit.
+        # 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
         # 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
         # for the changelist given all the fields to be edited. Then we'll
         # use the formset to validate/process POSTed data.
         # use the formset to validate/process POSTed data.
@@ -764,7 +945,11 @@ class ModelAdmin(BaseModelAdmin):
         if formset:
         if formset:
             media = self.media + formset.media
             media = self.media + formset.media
         else:
         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 = {
         context = {
             'title': cl.title,
             'title': cl.title,
@@ -774,6 +959,9 @@ class ModelAdmin(BaseModelAdmin):
             'has_add_permission': self.has_add_permission(request),
             'has_add_permission': self.has_add_permission(request),
             'root_path': self.admin_site.root_path,
             'root_path': self.admin_site.root_path,
             'app_label': app_label,
             '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 {})
         context.update(extra_context or {})
         return render_to_response(self.change_list_template 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
     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.
     that presents a full admin interface for the collection of registered models.
     """
     """
-    
+
     index_template = None
     index_template = None
     login_template = None
     login_template = None
     app_index_template = None
     app_index_template = None
-    
+
     def __init__(self, name=None):
     def __init__(self, name=None):
         self._registry = {} # model_class class -> admin_class instance
         self._registry = {} # model_class class -> admin_class instance
         # TODO Root path is used to calculate urls under the old root() method
         # TODO Root path is used to calculate urls under the old root() method
@@ -44,17 +44,19 @@ class AdminSite(object):
         else:
         else:
             name += '_'
             name += '_'
         self.name = name
         self.name = name
-    
+
+        self.actions = []
+
     def register(self, model_or_iterable, admin_class=None, **options):
     def register(self, model_or_iterable, admin_class=None, **options):
         """
         """
         Registers the given model(s) with the given admin class.
         Registers the given model(s) with the given admin class.
-        
+
         The model(s) should be Model classes, not instances.
         The model(s) should be Model classes, not instances.
-        
+
         If an admin class isn't given, it will use ModelAdmin (the default
         If an admin class isn't given, it will use ModelAdmin (the default
         admin options). If keyword arguments are given -- e.g., list_display --
         admin options). If keyword arguments are given -- e.g., list_display --
         they'll be applied as options to the admin class.
         they'll be applied as options to the admin class.
-        
+
         If a model is already registered, this will raise AlreadyRegistered.
         If a model is already registered, this will raise AlreadyRegistered.
         """
         """
         if not admin_class:
         if not admin_class:
@@ -65,13 +67,13 @@ class AdminSite(object):
             from django.contrib.admin.validation import validate
             from django.contrib.admin.validation import validate
         else:
         else:
             validate = lambda model, adminclass: None
             validate = lambda model, adminclass: None
-        
+
         if isinstance(model_or_iterable, ModelBase):
         if isinstance(model_or_iterable, ModelBase):
             model_or_iterable = [model_or_iterable]
             model_or_iterable = [model_or_iterable]
         for model in model_or_iterable:
         for model in model_or_iterable:
             if model in self._registry:
             if model in self._registry:
                 raise AlreadyRegistered('The model %s is already registered' % model.__name__)
                 raise AlreadyRegistered('The model %s is already registered' % model.__name__)
-            
+
             # If we got **options then dynamically construct a subclass of
             # If we got **options then dynamically construct a subclass of
             # admin_class with those **options.
             # admin_class with those **options.
             if options:
             if options:
@@ -80,17 +82,17 @@ class AdminSite(object):
                 # which causes issues later on.
                 # which causes issues later on.
                 options['__module__'] = __name__
                 options['__module__'] = __name__
                 admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
                 admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
-            
+
             # Validate (which might be a no-op)
             # Validate (which might be a no-op)
             validate(admin_class, model)
             validate(admin_class, model)
-            
+
             # Instantiate the admin class to save in the registry
             # Instantiate the admin class to save in the registry
             self._registry[model] = admin_class(model, self)
             self._registry[model] = admin_class(model, self)
-    
+
     def unregister(self, model_or_iterable):
     def unregister(self, model_or_iterable):
         """
         """
         Unregisters the given model(s).
         Unregisters the given model(s).
-        
+
         If a model isn't already registered, this will raise NotRegistered.
         If a model isn't already registered, this will raise NotRegistered.
         """
         """
         if isinstance(model_or_iterable, ModelBase):
         if isinstance(model_or_iterable, ModelBase):
@@ -99,44 +101,49 @@ class AdminSite(object):
             if model not in self._registry:
             if model not in self._registry:
                 raise NotRegistered('The model %s is not registered' % model.__name__)
                 raise NotRegistered('The model %s is not registered' % model.__name__)
             del self._registry[model]
             del self._registry[model]
-    
+
+    def add_action(self, action):
+        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):
     def has_permission(self, request):
         """
         """
         Returns True if the given HttpRequest has permission to view
         Returns True if the given HttpRequest has permission to view
         *at least one* page in the admin site.
         *at least one* page in the admin site.
         """
         """
         return request.user.is_authenticated() and request.user.is_staff
         return request.user.is_authenticated() and request.user.is_staff
-    
+
     def check_dependencies(self):
     def check_dependencies(self):
         """
         """
         Check that all things needed to run the admin have been correctly installed.
         Check that all things needed to run the admin have been correctly installed.
-        
+
         The default implementation checks that LogEntry, ContentType and the
         The default implementation checks that LogEntry, ContentType and the
         auth context processor are installed.
         auth context processor are installed.
         """
         """
         from django.contrib.admin.models import LogEntry
         from django.contrib.admin.models import LogEntry
         from django.contrib.contenttypes.models import ContentType
         from django.contrib.contenttypes.models import ContentType
-        
+
         if not LogEntry._meta.installed:
         if not LogEntry._meta.installed:
             raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.")
             raise ImproperlyConfigured("Put 'django.contrib.admin' in your INSTALLED_APPS setting in order to use the admin application.")
         if not ContentType._meta.installed:
         if not ContentType._meta.installed:
             raise ImproperlyConfigured("Put 'django.contrib.contenttypes' in your INSTALLED_APPS setting in order to use the admin application.")
             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:
         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.")
             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):
     def admin_view(self, view):
         """
         """
         Decorator to create an "admin view attached to this ``AdminSite``. This
         Decorator to create an "admin view attached to this ``AdminSite``. This
         wraps the view and provides permission checking by calling
         wraps the view and provides permission checking by calling
         ``self.has_permission``.
         ``self.has_permission``.
-        
+
         You'll want to use this from within ``AdminSite.get_urls()``:
         You'll want to use this from within ``AdminSite.get_urls()``:
-            
+
             class MyAdminSite(AdminSite):
             class MyAdminSite(AdminSite):
-                
+
                 def get_urls(self):
                 def get_urls(self):
                     from django.conf.urls.defaults import patterns, url
                     from django.conf.urls.defaults import patterns, url
-                    
+
                     urls = super(MyAdminSite, self).get_urls()
                     urls = super(MyAdminSite, self).get_urls()
                     urls += patterns('',
                     urls += patterns('',
                         url(r'^my_view/$', self.protected_view(some_view))
                         url(r'^my_view/$', self.protected_view(some_view))
@@ -148,15 +155,15 @@ class AdminSite(object):
                 return self.login(request)
                 return self.login(request)
             return view(request, *args, **kwargs)
             return view(request, *args, **kwargs)
         return update_wrapper(inner, view)
         return update_wrapper(inner, view)
-    
+
     def get_urls(self):
     def get_urls(self):
         from django.conf.urls.defaults import patterns, url, include
         from django.conf.urls.defaults import patterns, url, include
-        
+
         def wrap(view):
         def wrap(view):
             def wrapper(*args, **kwargs):
             def wrapper(*args, **kwargs):
                 return self.admin_view(view)(*args, **kwargs)
                 return self.admin_view(view)(*args, **kwargs)
             return update_wrapper(wrapper, view)
             return update_wrapper(wrapper, view)
-        
+
         # Admin-site-wide views.
         # Admin-site-wide views.
         urlpatterns = patterns('',
         urlpatterns = patterns('',
             url(r'^$',
             url(r'^$',
@@ -180,7 +187,7 @@ class AdminSite(object):
                 wrap(self.app_index),
                 wrap(self.app_index),
                 name='%sadmin_app_list' % self.name),
                 name='%sadmin_app_list' % self.name),
         )
         )
-        
+
         # Add in each model's views.
         # Add in each model's views.
         for model, model_admin in self._registry.iteritems():
         for model, model_admin in self._registry.iteritems():
             urlpatterns += patterns('',
             urlpatterns += patterns('',
@@ -188,11 +195,11 @@ class AdminSite(object):
                     include(model_admin.urls))
                     include(model_admin.urls))
             )
             )
         return urlpatterns
         return urlpatterns
-    
+
     def urls(self):
     def urls(self):
         return self.get_urls()
         return self.get_urls()
     urls = property(urls)
     urls = property(urls)
-        
+
     def password_change(self, request):
     def password_change(self, request):
         """
         """
         Handles the "change password" task -- both form display and validation.
         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
         from django.contrib.auth.views import password_change
         return password_change(request,
         return password_change(request,
             post_change_redirect='%spassword_change/done/' % self.root_path)
             post_change_redirect='%spassword_change/done/' % self.root_path)
-    
+
     def password_change_done(self, request):
     def password_change_done(self, request):
         """
         """
         Displays the "success" page after a password change.
         Displays the "success" page after a password change.
         """
         """
         from django.contrib.auth.views import password_change_done
         from django.contrib.auth.views import password_change_done
         return password_change_done(request)
         return password_change_done(request)
-    
+
     def i18n_javascript(self, request):
     def i18n_javascript(self, request):
         """
         """
         Displays the i18n JavaScript that the Django admin requires.
         Displays the i18n JavaScript that the Django admin requires.
-        
+
         This takes into account the USE_I18N setting. If it's set to False, the
         This takes into account the USE_I18N setting. If it's set to False, the
         generated JavaScript will be leaner and faster.
         generated JavaScript will be leaner and faster.
         """
         """
@@ -220,23 +227,23 @@ class AdminSite(object):
         else:
         else:
             from django.views.i18n import null_javascript_catalog as javascript_catalog
             from django.views.i18n import null_javascript_catalog as javascript_catalog
         return javascript_catalog(request, packages='django.conf')
         return javascript_catalog(request, packages='django.conf')
-    
+
     def logout(self, request):
     def logout(self, request):
         """
         """
         Logs out the user for the given HttpRequest.
         Logs out the user for the given HttpRequest.
-        
+
         This should *not* assume the user is already logged in.
         This should *not* assume the user is already logged in.
         """
         """
         from django.contrib.auth.views import logout
         from django.contrib.auth.views import logout
         return logout(request)
         return logout(request)
     logout = never_cache(logout)
     logout = never_cache(logout)
-    
+
     def login(self, request):
     def login(self, request):
         """
         """
         Displays the login form for the given HttpRequest.
         Displays the login form for the given HttpRequest.
         """
         """
         from django.contrib.auth.models import User
         from django.contrib.auth.models import User
-        
+
         # If this isn't already the login page, display it.
         # If this isn't already the login page, display it.
         if not request.POST.has_key(LOGIN_FORM_KEY):
         if not request.POST.has_key(LOGIN_FORM_KEY):
             if request.POST:
             if request.POST:
@@ -244,14 +251,14 @@ class AdminSite(object):
             else:
             else:
                 message = ""
                 message = ""
             return self.display_login_form(request, message)
             return self.display_login_form(request, message)
-        
+
         # Check that the user accepts cookies.
         # Check that the user accepts cookies.
         if not request.session.test_cookie_worked():
         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.")
             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)
             return self.display_login_form(request, message)
         else:
         else:
             request.session.delete_test_cookie()
             request.session.delete_test_cookie()
-        
+
         # Check the password.
         # Check the password.
         username = request.POST.get('username', None)
         username = request.POST.get('username', None)
         password = request.POST.get('password', None)
         password = request.POST.get('password', None)
@@ -271,7 +278,7 @@ class AdminSite(object):
                     else:
                     else:
                         message = _("Usernames cannot contain the '@' character.")
                         message = _("Usernames cannot contain the '@' character.")
             return self.display_login_form(request, message)
             return self.display_login_form(request, message)
-        
+
         # The user data is correct; log in the user in and continue.
         # The user data is correct; log in the user in and continue.
         else:
         else:
             if user.is_active and user.is_staff:
             if user.is_active and user.is_staff:
@@ -280,7 +287,7 @@ class AdminSite(object):
             else:
             else:
                 return self.display_login_form(request, ERROR_MESSAGE)
                 return self.display_login_form(request, ERROR_MESSAGE)
     login = never_cache(login)
     login = never_cache(login)
-    
+
     def index(self, request, extra_context=None):
     def index(self, request, extra_context=None):
         """
         """
         Displays the main admin index page, which lists all of the installed
         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():
         for model, model_admin in self._registry.items():
             app_label = model._meta.app_label
             app_label = model._meta.app_label
             has_module_perms = user.has_module_perms(app_label)
             has_module_perms = user.has_module_perms(app_label)
-            
+
             if has_module_perms:
             if has_module_perms:
                 perms = {
                 perms = {
                     'add': model_admin.has_add_permission(request),
                     'add': model_admin.has_add_permission(request),
                     'change': model_admin.has_change_permission(request),
                     'change': model_admin.has_change_permission(request),
                     'delete': model_admin.has_delete_permission(request),
                     'delete': model_admin.has_delete_permission(request),
                 }
                 }
-                
+
                 # Check whether user has any perm for this module.
                 # Check whether user has any perm for this module.
                 # If so, add the module to the model_list.
                 # If so, add the module to the model_list.
                 if True in perms.values():
                 if True in perms.values():
@@ -316,15 +323,15 @@ class AdminSite(object):
                             'has_module_perms': has_module_perms,
                             'has_module_perms': has_module_perms,
                             'models': [model_dict],
                             'models': [model_dict],
                         }
                         }
-        
+
         # Sort the apps alphabetically.
         # Sort the apps alphabetically.
         app_list = app_dict.values()
         app_list = app_dict.values()
         app_list.sort(lambda x, y: cmp(x['name'], y['name']))
         app_list.sort(lambda x, y: cmp(x['name'], y['name']))
-        
+
         # Sort the models alphabetically within each app.
         # Sort the models alphabetically within each app.
         for app in app_list:
         for app in app_list:
             app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
             app['models'].sort(lambda x, y: cmp(x['name'], y['name']))
-        
+
         context = {
         context = {
             'title': _('Site administration'),
             'title': _('Site administration'),
             'app_list': app_list,
             'app_list': app_list,
@@ -335,7 +342,7 @@ class AdminSite(object):
             context_instance=template.RequestContext(request)
             context_instance=template.RequestContext(request)
         )
         )
     index = never_cache(index)
     index = never_cache(index)
-    
+
     def display_login_form(self, request, error_message='', extra_context=None):
     def display_login_form(self, request, error_message='', extra_context=None):
         request.session.set_test_cookie()
         request.session.set_test_cookie()
         context = {
         context = {
@@ -348,7 +355,7 @@ class AdminSite(object):
         return render_to_response(self.login_template or 'admin/login.html', context,
         return render_to_response(self.login_template or 'admin/login.html', context,
             context_instance=template.RequestContext(request)
             context_instance=template.RequestContext(request)
         )
         )
-    
+
     def app_index(self, request, app_label, extra_context=None):
     def app_index(self, request, app_label, extra_context=None):
         user = request.user
         user = request.user
         has_module_perms = user.has_module_perms(app_label)
         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,
         return render_to_response(self.app_index_template or 'admin/app_index.html', context,
             context_instance=template.RequestContext(request)
             context_instance=template.RequestContext(request)
         )
         )
-        
+
     def root(self, request, url):
     def root(self, request, url):
         """
         """
         DEPRECATED. This function is the old way of handling URL resolution, and
         DEPRECATED. This function is the old way of handling URL resolution, and
         is deprecated in favor of real URL resolution -- see ``get_urls()``.
         is deprecated in favor of real URL resolution -- see ``get_urls()``.
-        
+
         This function still exists for backwards-compatibility; it will be
         This function still exists for backwards-compatibility; it will be
         removed in Django 1.3.
         removed in Django 1.3.
         """
         """
         import warnings
         import warnings
         warnings.warn(
         warnings.warn(
-            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.", 
+            "AdminSite.root() is deprecated; use include(admin.site.urls) instead.",
             PendingDeprecationWarning
             PendingDeprecationWarning
         )
         )
-        
+
         #
         #
         # Again, remember that the following only exists for
         # Again, remember that the following only exists for
         # backwards-compatibility. Any new URLs, changes to existing URLs, or
         # backwards-compatibility. Any new URLs, changes to existing URLs, or
         # whatever need to be done up in get_urls(), above!
         # whatever need to be done up in get_urls(), above!
         #
         #
-        
+
         if request.method == 'GET' and not request.path.endswith('/'):
         if request.method == 'GET' and not request.path.endswith('/'):
             return http.HttpResponseRedirect(request.path + '/')
             return http.HttpResponseRedirect(request.path + '/')
-        
+
         if settings.DEBUG:
         if settings.DEBUG:
             self.check_dependencies()
             self.check_dependencies()
-        
+
         # Figure out the admin base URL path and stash it for later use
         # Figure out the admin base URL path and stash it for later use
         self.root_path = re.sub(re.escape(url) + '$', '', request.path)
         self.root_path = re.sub(re.escape(url) + '$', '', request.path)
-        
+
         url = url.rstrip('/') # Trim trailing slash, if it exists.
         url = url.rstrip('/') # Trim trailing slash, if it exists.
-        
+
         # The 'logout' view doesn't require that the person is logged in.
         # The 'logout' view doesn't require that the person is logged in.
         if url == 'logout':
         if url == 'logout':
             return self.logout(request)
             return self.logout(request)
-        
+
         # Check permission to continue or display login form.
         # Check permission to continue or display login form.
         if not self.has_permission(request):
         if not self.has_permission(request):
             return self.login(request)
             return self.login(request)
-        
+
         if url == '':
         if url == '':
             return self.index(request)
             return self.index(request)
         elif url == 'password_change':
         elif url == 'password_change':
@@ -451,9 +458,9 @@ class AdminSite(object):
                 return self.model_page(request, *url.split('/', 2))
                 return self.model_page(request, *url.split('/', 2))
             else:
             else:
                 return self.app_index(request, url)
                 return self.app_index(request, url)
-        
+
         raise http.Http404('The requested admin page does not exist.')
         raise http.Http404('The requested admin page does not exist.')
-        
+
     def model_page(self, request, app_label, model_name, rest_of_url=None):
     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
         DEPRECATED. This is the old way of handling a model view on the admin
@@ -468,7 +475,7 @@ class AdminSite(object):
         except KeyError:
         except KeyError:
             raise http.Http404("This model exists but has not been registered with the admin site.")
             raise http.Http404("This model exists but has not been registered with the admin site.")
         return admin_obj(request, rest_of_url)
         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.
 # 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.
 # 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 %}
   {% if cl.formset %}
     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
     <script type="text/javascript" src="../../jsi18n/"></script>
     <script type="text/javascript" src="../../jsi18n/"></script>
-    {{ media }}
   {% endif %}
   {% endif %}
+  {{ media }}
 {% endblock %}
 {% endblock %}
 
 
 {% block bodyclass %}change-list{% endblock %}
 {% block bodyclass %}change-list{% endblock %}
@@ -63,14 +63,18 @@
         {% endif %}
         {% endif %}
       {% endblock %}
       {% endblock %}
       
       
+      <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
       {% if cl.formset %}
       {% if cl.formset %}
-        <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
         {{ cl.formset.management_form }}
         {{ cl.formset.management_form }}
       {% endif %}
       {% 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 %}
       {% block pagination %}{% pagination cl %}{% endblock %}
-      {% if cl.formset %}</form>{% endif %}
+      </form>
     </div>
     </div>
   </div>
   </div>
 {% endblock %}
 {% 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):
 def admin_list_filter(cl, spec):
     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
 admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
 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.safestring import mark_safe
 from django.utils.text import capfirst
 from django.utils.text import capfirst
 from django.utils.encoding import force_unicode
 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):
 def quote(s):
     """
     """
@@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
         current = current[-1]
         current = current[-1]
     current.append(val)
     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
     nh = _nest_help # Bind to local variable for performance
     if current_depth > 16:
     if current_depth > 16:
         return # Avoid recursing too deep.
         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)), []])
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                 else:
                     # Display a link to the admin page.
                     # 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)),
                         (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))), []])
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
         else:
         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)), []])
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                 else:
                     # Display a link to the admin page.
                     # 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)),
                         (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))), []])
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
                 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
             # 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.
                     # Display a link to the admin page.
                     nh(deleted_objects, current_depth, [
                     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))}) + \
                         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
         # If there were related objects, and the user doesn't have
         # permission to change them, add the missing perm to perms_needed.
         # permission to change them, add the missing perm to perms_needed.
         if has_admin and has_related_objs:
         if has_admin and has_related_objs:
             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
             if not user.has_perm(p):
             if not user.has_perm(p):
                 perms_needed.add(related.opts.verbose_name)
                 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):
     if hasattr(cls, 'list_per_page') and not isinstance(cls.list_per_page, int):
         raise ImproperlyConfigured("'%s.list_per_page' should be a integer."
         raise ImproperlyConfigured("'%s.list_per_page' should be a integer."
                 % cls.__name__)
                 % cls.__name__)
-    
+
     # list_editable
     # list_editable
     if hasattr(cls, 'list_editable') and cls.list_editable:
     if hasattr(cls, 'list_editable') and cls.list_editable:
         check_isseq(cls, 'list_editable', 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]
                 field = opts.get_field_by_name(field_name)[0]
             except models.FieldDoesNotExist:
             except models.FieldDoesNotExist:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
                 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__))
                     % (cls.__name__, idx, field_name, model.__name__))
             if field_name not in cls.list_display:
             if field_name not in cls.list_display:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to "
                 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:
             if not cls.list_display_links and cls.list_display[0] in cls.list_editable:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to"
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to"
                     " the first field in list_display, '%s', which can't be"
                     " 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]))
                     % (cls.__name__, idx, cls.list_display[0]))
             if not field.editable:
             if not field.editable:
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
                 raise ImproperlyConfigured("'%s.list_editable[%d]' refers to a "
@@ -127,6 +127,14 @@ def validate(cls, model):
                 continue
                 continue
             get_field(cls, model, opts, 'ordering[%d]' % idx, field)
             get_field(cls, model, opts, 'ordering[%d]' % idx, field)
 
 
+    if cls.actions:
+        check_isseq(cls, 'actions', cls.actions)
+        for idx, item in enumerate(cls.actions):
+            if (not callable(item)) and (not hasattr(cls, item)):
+                raise ImproperlyConfigured("'%s.actions[%d]' is neither a "
+                    "callable nor a method on %s" % (cls.__name__, idx, cls.__name__))
+
+
     # list_select_related = False
     # list_select_related = False
     # save_as = False
     # save_as = False
     # save_on_top = False
     # save_on_top = False
@@ -135,6 +143,7 @@ def validate(cls, model):
             raise ImproperlyConfigured("'%s.%s' should be a boolean."
             raise ImproperlyConfigured("'%s.%s' should be a boolean."
                     % (cls.__name__, attr))
                     % (cls.__name__, attr))
 
 
+
     # inlines = []
     # inlines = []
     if hasattr(cls, 'inlines'):
     if hasattr(cls, 'inlines'):
         check_isseq(cls, 'inlines', cls.inlines)
         check_isseq(cls, 'inlines', cls.inlines)

+ 1 - 1
docs/index.txt

@@ -78,7 +78,7 @@ The development process
 Other batteries included
 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:`Authentication <topics-auth>`
     * :ref:`Cache system <topics-cache>`
     * :ref:`Cache system <topics-cache>`
     * :ref:`Conditional content processing <topics-conditional-processing>`
     * :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.
        ``ModelAdmin`` classes.
 
 
     5. Hook the ``AdminSite`` instance into your URLconf.
     5. Hook the ``AdminSite`` instance into your URLconf.
+    
+Other topics
+------------
+
+.. toctree::
+   :maxdepth: 1
+   
+   actions
 
 
 ``ModelAdmin`` objects
 ``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
     that have ``raw_id_fields`` or ``radio_fields`` set. That's because
     ``raw_id_fields`` and ``radio_fields`` imply custom widgets of their own.
     ``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
 ``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
 Python class), and register your models and ``ModelAdmin`` subclasses
 with it instead of using the default.
 with it instead of using the default.
 
 
-
 Hooking ``AdminSite`` instances into your URLconf
 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
 instance since you will likely be importing all the per-app admin.py modules
 in your ``myproject.admin`` module.
 in your ``myproject.admin`` module.
 
 
-
 Multiple admin sites in the same URLconf
 Multiple admin sites in the same URLconf
 ----------------------------------------
 ----------------------------------------
 
 

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

@@ -24,7 +24,7 @@ those packages have.
 .. toctree::
 .. toctree::
    :maxdepth: 1
    :maxdepth: 1
 
 
-   admin
+   admin/index
    auth
    auth
    comments/index
    comments/index
    contenttypes
    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
 >>> site._registry[Person].search_fields
 ['name']
 ['name']
 >>> site._registry[Person].list_display
 >>> site._registry[Person].list_display
-['__str__']
+['action_checkbox', '__str__']
 >>> site._registry[Person].save_on_top
 >>> site._registry[Person].save_on_top
 True
 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 -*-
 # -*- coding: utf-8 -*-
 from django.db import models
 from django.db import models
 from django.contrib import admin
 from django.contrib import admin
+from django.core.mail import EmailMessage
 
 
 class Section(models.Model):
 class Section(models.Model):
     """
     """
@@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
         BarAccountAdmin
         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(Article, ArticleAdmin)
 admin.site.register(CustomArticle, CustomArticleAdmin)
 admin.site.register(CustomArticle, CustomArticleAdmin)
@@ -208,6 +244,8 @@ admin.site.register(Color)
 admin.site.register(Thing, ThingAdmin)
 admin.site.register(Thing, ThingAdmin)
 admin.site.register(Person, PersonAdmin)
 admin.site.register(Person, PersonAdmin)
 admin.site.register(Persona, PersonaAdmin)
 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.
 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
 # That way we cover all four cases:
 # 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.models import LogEntry
 from django.contrib.admin.sites import LOGIN_FORM_KEY
 from django.contrib.admin.sites import LOGIN_FORM_KEY
 from django.contrib.admin.util import quote
 from django.contrib.admin.util import quote
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.utils.html import escape
 from django.utils.html import escape
 
 
 # local test models
 # 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:
 try:
     set
     set
@@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
     def test_changelist_to_changeform_link(self):
     def test_changelist_to_changeform_link(self):
         "The link from the changelist referring to the changeform of the object should be quoted"
         "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/')
         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)
         self.assertContains(response, should_contain)
 
 
     def test_recentactions_link(self):
     def test_recentactions_link(self):
@@ -738,29 +739,30 @@ class AdminViewListEditable(TestCase):
 
 
     def tearDown(self):
     def tearDown(self):
         self.client.logout()
         self.client.logout()
-    
+
     def test_changelist_input_html(self):
     def test_changelist_input_html(self):
         response = self.client.get('/test_admin/admin/admin_views/person/')
         response = self.client.get('/test_admin/admin/admin_views/person/')
         # 2 inputs per object(the field and the hidden id field) = 6
         # 2 inputs per object(the field and the hidden id field) = 6
         # 2 management hidden fields = 2
         # 2 management hidden fields = 2
+        # 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
         # main form submit button = 1
         # main form submit button = 1
         # search field and search submit button = 2
         # search field and search submit button = 2
         # 6 + 2 + 1 + 2 = 11 inputs
         # 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
         # 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):
     def test_post_submission(self):
         data = {
         data = {
             "form-TOTAL_FORMS": "3",
             "form-TOTAL_FORMS": "3",
             "form-INITIAL_FORMS": "3",
             "form-INITIAL_FORMS": "3",
-            
+
             "form-0-gender": "1",
             "form-0-gender": "1",
             "form-0-id": "1",
             "form-0-id": "1",
-            
+
             "form-1-gender": "2",
             "form-1-gender": "2",
             "form-1-id": "2",
             "form-1-id": "2",
-            
+
             "form-2-alive": "checked",
             "form-2-alive": "checked",
             "form-2-gender": "1",
             "form-2-gender": "1",
             "form-2-id": "3",
             "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="John Mauchly").alive, False)
         self.failUnlessEqual(Person.objects.get(name="Grace Hooper").gender, 2)
         self.failUnlessEqual(Person.objects.get(name="Grace Hooper").gender, 2)
-        
+
         # test a filtered page
         # test a filtered page
         data = {
         data = {
             "form-TOTAL_FORMS": "2",
             "form-TOTAL_FORMS": "2",
             "form-INITIAL_FORMS": "2",
             "form-INITIAL_FORMS": "2",
-            
+
             "form-0-id": "1",
             "form-0-id": "1",
             "form-0-gender": "1",
             "form-0-gender": "1",
             "form-0-alive": "checked",
             "form-0-alive": "checked",
-            
+
             "form-1-id": "3",
             "form-1-id": "3",
             "form-1-gender": "1",
             "form-1-gender": "1",
             "form-1-alive": "checked",
             "form-1-alive": "checked",
         }
         }
         self.client.post('/test_admin/admin/admin_views/person/?gender__exact=1', data)
         self.client.post('/test_admin/admin/admin_views/person/?gender__exact=1', data)
-        
+
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, True)
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, True)
-        
+
         # test a searched page
         # test a searched page
         data = {
         data = {
             "form-TOTAL_FORMS": "1",
             "form-TOTAL_FORMS": "1",
             "form-INITIAL_FORMS": "1",
             "form-INITIAL_FORMS": "1",
-            
+
             "form-0-id": "1",
             "form-0-id": "1",
             "form-0-gender": "1"
             "form-0-gender": "1"
         }
         }
         self.client.post('/test_admin/admin/admin_views/person/?q=mauchly', data)
         self.client.post('/test_admin/admin/admin_views/person/?q=mauchly', data)
-        
+
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
         self.failUnlessEqual(Person.objects.get(name="John Mauchly").alive, False)
 
 
 class AdminInheritedInlinesTest(TestCase):
 class AdminInheritedInlinesTest(TestCase):
@@ -875,3 +877,65 @@ class AdminInheritedInlinesTest(TestCase):
         self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
         self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
         self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
         self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
         self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
         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)