diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py
index aaa2e30..aac8911 100644
--- a/django/contrib/admin/helpers.py
+++ b/django/contrib/admin/helpers.py
@@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
 from django.utils.encoding import force_unicode
 from django.contrib.admin.util import flatten_fieldsets
 from django.contrib.contenttypes.models import ContentType
+from django.utils.translation import ugettext_lazy as _
+
+ACTION_CHECKBOX_NAME = 'selected'
+
+class ActionForm(forms.Form):
+    action = forms.ChoiceField(label=_('Action:'))
+
+checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
 
 class AdminForm(object):
     def __init__(self, form, fieldsets, prepopulated_fields):
diff --git a/django/contrib/admin/media/css/changelists.css b/django/contrib/admin/media/css/changelists.css
index 40142f5..649cff7 100644
--- a/django/contrib/admin/media/css/changelists.css
+++ b/django/contrib/admin/media/css/changelists.css
@@ -50,12 +50,24 @@
 
 #changelist table thead th {
     white-space: nowrap;
+    vertical-align: middle;
+}
+
+#changelist table thead th:first-child {
+    width: 1.5em;
+    text-align: center;
 }
 
 #changelist table tbody td {
     border-left: 1px solid #ddd;
 }
 
+#changelist table tbody td:first-child {
+    border-left: 0;
+    border-right: 1px solid #ddd;
+    text-align: center;
+}
+
 #changelist table tfoot {
     color: #666;
 }
@@ -209,3 +221,35 @@
     border-color: #036;
 }
 
+/* ACTIONS */
+
+.filtered .actions {
+    margin-right: 160px !important;
+    border-right: 1px solid #ddd;
+}
+
+#changelist .actions {
+    color: #666;
+    padding: 3px;
+    border-bottom: 1px solid #ddd;
+    background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
+}
+
+#changelist .actions:last-child {
+    border-bottom: none;
+}
+
+#changelist .actions select {
+    border: 1px solid #aaa;
+    margin: 0 0.5em;
+    padding: 1px 2px;
+}
+
+#changelist .actions label {
+    font-size: 11px;
+    margin: 0 0.5em;
+}
+
+#changelist #action-toggle {
+    display: none;
+}
diff --git a/django/contrib/admin/media/js/actions.js b/django/contrib/admin/media/js/actions.js
new file mode 100644
index 0000000..febb0c1
--- /dev/null
+++ b/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);
diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
index 69f52aa..8c72019 100644
--- a/django/contrib/admin/options.py
+++ b/django/contrib/admin/options.py
@@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
 from django.contrib.contenttypes.models import ContentType
 from django.contrib.admin import widgets
 from django.contrib.admin import helpers
-from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
+from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
 from django.core.exceptions import PermissionDenied
 from django.db import models, transaction
+from django.db.models.fields import BLANK_CHOICE_DASH
 from django.http import Http404, HttpResponse, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, render_to_response
 from django.utils.functional import update_wrapper
@@ -16,7 +17,7 @@ from django.utils.safestring import mark_safe
 from django.utils.functional import curry
 from django.utils.text import capfirst, get_text_list
 from django.utils.translation import ugettext as _
-from django.utils.translation import ngettext
+from django.utils.translation import ngettext, ugettext_lazy
 from django.utils.encoding import force_unicode
 try:
     set
@@ -172,8 +173,8 @@ class BaseModelAdmin(object):
 class ModelAdmin(BaseModelAdmin):
     "Encapsulates all admin options and functionality for a given model."
     __metaclass__ = forms.MediaDefiningClass
-
-    list_display = ('__str__',)
+    
+    list_display = ('action_checkbox', '__str__',)
     list_display_links = ()
     list_filter = ()
     list_select_related = False
@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
     delete_confirmation_template = None
     object_history_template = None
 
+    # Actions
+    actions = ['delete_selected']
+    action_form = helpers.ActionForm
+    actions_on_top = False
+    actions_on_bottom = True
+
     def __init__(self, model, admin_site):
         self.model = model
         self.opts = model._meta
@@ -200,6 +207,14 @@ class ModelAdmin(BaseModelAdmin):
         for inline_class in self.inlines:
             inline_instance = inline_class(self.model, self.admin_site)
             self.inline_instances.append(inline_instance)
+        if 'action_checkbox' not in self.list_display:
+            self.list_display = list(self.list_display)
+            self.list_display.insert(0, 'action_checkbox')
+        if not self.list_display_links:
+            for name in self.list_display:
+                if name != 'action_checkbox':
+                    self.list_display_links = [name]
+                    break
         super(ModelAdmin, self).__init__()
 
     def get_urls(self):
@@ -239,6 +254,8 @@ class ModelAdmin(BaseModelAdmin):
         from django.conf import settings
 
         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
+        if self.actions:
+            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
         if self.prepopulated_fields:
             js.append('js/urlify.js')
         if self.opts.get_ordered_objects():
@@ -390,6 +407,96 @@ class ModelAdmin(BaseModelAdmin):
             action_flag     = DELETION
         )
 
+    def action_checkbox(self, obj):
+        """
+        A list_display column containing a checkbox widget.
+        """
+        return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
+    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
+    action_checkbox.allow_tags = True
+
+
+    def get_actions(self, request=None):
+        actions = {}
+        for klass in self.__class__.mro():
+            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, default_choices=BLANK_CHOICE_DASH, request=None):
+        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):
+        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, selected):
+        """
+        Default action which deletes the selected objects.
+        """
+        opts = self.model._meta
+        app_label = opts.app_label
+
+        if not self.has_delete_permission(request):
+            raise PermissionDenied
+
+        # Populate deleted_objects, a data structure of all related objects that
+        # will also be deleted.
+
+        # deleted_objects must be a list if we want to use '|unordered_list' in the template
+        deleted_objects = []
+        perms_needed = set()
+        i = 0
+        for obj in selected:
+            deleted_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(deleted_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.
+        if request.POST.get('post'):
+            if perms_needed:
+                raise PermissionDenied
+            n = selected.count()
+            if n:
+                for obj in selected:
+                    obj_display = force_unicode(obj)
+                    self.log_deletion(request, obj, obj_display)
+                selected.delete()
+                self.message_user(request, _("Successfully deleted %d %s.") % (
+                    n, model_ngettext(self.opts, n)
+                ))
+            return None
+
+        context = {
+            "title": _("Are you sure?"),
+            "object_name": force_unicode(opts.verbose_name),
+            "deleted_objects": deleted_objects,
+            'selected': selected,
+            "perms_lacking": perms_needed,
+            "opts": opts,
+            "root_path": self.admin_site.root_path,
+            "app_label": app_label,
+        }
+        return render_to_response(self.delete_confirmation_template or [
+            "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
+            "admin/%s/delete_selected_confirmation.html" % app_label,
+            "admin/delete_selected_confirmation.html"
+        ], context, context_instance=template.RequestContext(request))
+
+    delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
 
     def construct_change_message(self, request, form, formsets):
         """
@@ -529,6 +636,40 @@ class ModelAdmin(BaseModelAdmin):
             self.message_user(request, msg)
             return HttpResponseRedirect("../")
 
+    def response_action(self, request, queryset):
+        if request.method == 'POST':
+            # 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
+            data = {}
+            for key in request.POST:
+                if key not in (helpers.ACTION_CHECKBOX_NAME, 'index'):
+                    data[key] = request.POST.getlist(key)[action_index]
+            action_form = self.action_form(data, auto_id=None)
+            action_form.fields['action'].choices = self.get_action_choices(request)
+
+            if action_form.is_valid():
+                action = action_form.cleaned_data['action']
+                func, name, description = self.get_actions(request)[action]
+                selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+                results = queryset.filter(pk__in=selected)
+                response = None
+                if callable(func):
+                    response = func(request, results)
+                if isinstance(response, HttpResponse):
+                    return response
+                else:
+                    redirect_to = request.META.get('HTTP_REFERER') or "."
+                    return HttpResponseRedirect(redirect_to)
+        else:
+            action_form = self.action_form(auto_id=None)
+            action_form.fields['action'].choices = self.get_action_choices(request)
+        return action_form
+
     def add_view(self, request, form_url='', extra_context=None):
         "The 'add' admin view for this model."
         model = self.model
@@ -721,6 +862,10 @@ class ModelAdmin(BaseModelAdmin):
                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
 
+        action_form_or_response = self.response_action(request, queryset=cl.get_query_set())
+        if isinstance(action_form_or_response, HttpResponse):
+            return action_form_or_response
+
         # If we're allowing changelist editing, we need to construct a formset
         # for the changelist given all the fields to be edited. Then we'll
         # use the formset to validate/process POSTed data.
@@ -764,8 +909,8 @@ class ModelAdmin(BaseModelAdmin):
         if formset:
             media = self.media + formset.media
         else:
-            media = None
-
+            media = self.media
+        
         context = {
             'title': cl.title,
             'is_popup': cl.is_popup,
@@ -774,6 +919,9 @@ class ModelAdmin(BaseModelAdmin):
             'has_add_permission': self.has_add_permission(request),
             'root_path': self.admin_site.root_path,
             'app_label': app_label,
+            'action_form': action_form_or_response,
+            'actions_on_top': self.actions_on_top,
+            'actions_on_bottom': self.actions_on_bottom,
         }
         context.update(extra_context or {})
         return render_to_response(self.change_list_template or [
diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py
index 5171e71..ebcf886 100644
--- a/django/contrib/admin/sites.py
+++ b/django/contrib/admin/sites.py
@@ -44,6 +44,8 @@ class AdminSite(object):
         else:
             name += '_'
         self.name = name
+        
+        self.actions = []
     
     def register(self, model_or_iterable, admin_class=None, **options):
         """
@@ -81,6 +83,9 @@ class AdminSite(object):
                 options['__module__'] = __name__
                 admin_class = type("%sAdmin" % model.__name__, (admin_class,), options)
             
+            for action in self.actions:
+                admin_class.actions.append(action)
+            
             # Validate (which might be a no-op)
             validate(admin_class, model)
             
@@ -100,6 +105,13 @@ class AdminSite(object):
                 raise NotRegistered('The model %s is not registered' % model.__name__)
             del self._registry[model]
     
+    def add_action(self, action):
+        if not callable(action):
+            raise TypeError("You can only register callable actions through an admin site")
+        self.actions.append(action)
+        for klass in self._registery.itervalues():
+            klass.actions.append(action)
+    
     def has_permission(self, request):
         """
         Returns True if the given HttpRequest has permission to view
diff --git a/django/contrib/admin/templates/admin/actions.html b/django/contrib/admin/templates/admin/actions.html
new file mode 100644
index 0000000..bf4b975
--- /dev/null
+++ b/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>
diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
index dca5b80..63254b8 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -7,8 +7,8 @@
   {% if cl.formset %}
     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
     <script type="text/javascript" src="../../jsi18n/"></script>
-    {{ media }}
   {% endif %}
+  {{ media }}
 {% endblock %}
 
 {% block bodyclass %}change-list{% endblock %}
@@ -63,14 +63,18 @@
         {% endif %}
       {% endblock %}
       
+      <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
       {% if cl.formset %}
-        <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
         {{ cl.formset.management_form }}
       {% endif %}
 
-      {% block result_list %}{% result_list cl %}{% endblock %}
+      {% block result_list %}
+          {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
+          {% result_list cl %}
+          {% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
+      {% endblock %}
       {% block pagination %}{% pagination cl %}{% endblock %}
-      {% if cl.formset %}</form>{% endif %}
+      </form>
     </div>
   </div>
 {% endblock %}
diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html
new file mode 100644
index 0000000..183134d
--- /dev/null
+++ b/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 d in deleted_objects %}
+        <ul>{{ d|unordered_list }}</ul>
+    {% endfor %}
+    <form action="" method="post">
+    <div>
+    {% for s in selected %}
+    <input type="hidden" name="selected" value="{{ s.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 %}
\ No newline at end of file
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index 063ef0e..a374bf5 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
 def admin_list_filter(cl, spec):
     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
 admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
+
+def admin_actions(context):
+    """
+    Track the number of times the action field has been rendered on the page,
+    so we know which value to use.
+    """
+    context['action_index'] = context.get('action_index', -1) + 1
+    return context
+admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
index 4164c8a..38f86e3 100644
--- a/django/contrib/admin/util.py
+++ b/django/contrib/admin/util.py
@@ -4,7 +4,8 @@ from django.utils.html import escape
 from django.utils.safestring import mark_safe
 from django.utils.text import capfirst
 from django.utils.encoding import force_unicode
-from django.utils.translation import ugettext as _
+from django.utils.translation import ungettext, ugettext as _
+from django.core.urlresolvers import reverse, NoReverseMatch
 
 def quote(s):
     """
@@ -60,8 +61,27 @@ def _nest_help(obj, depth, val):
         current = current[-1]
     current.append(val)
 
-def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site):
-    "Helper function that recursively populates deleted_objects."
+def get_change_view_url(app_label, module_name, pk, admin_site, levels_to_root):
+    """
+    Returns the url to the admin change view for the given app_label,
+    module_name and primary key.
+    """
+    try:
+        return reverse('%sadmin_%s_%s_change' % (admin_site.name, app_label, module_name), None, (pk,))
+    except NoReverseMatch:
+        return '%s%s/%s/%s/' % ('../'*levels_to_root, app_label, module_name, pk)
+
+def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_depth, admin_site, levels_to_root=4):
+    """
+    Helper function that recursively populates deleted_objects.
+
+    `levels_to_root` defines the number of directories (../) to reach the
+    admin root path. In a change_view this is 4, in a change_list view 2.
+
+    This is for backwards compatibility since the options.delete_selected
+    method uses this function also from a change_list view.
+    This will not be used if we can reverse the URL.
+    """
     nh = _nest_help # Bind to local variable for performance
     if current_depth > 16:
         return # Avoid recursing too deep.
@@ -91,11 +111,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                     # Display a link to the admin page.
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' %
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
                         (escape(capfirst(related.opts.verbose_name)),
-                        related.opts.app_label,
-                        related.opts.object_name.lower(),
-                        sub_obj._get_pk_val(),
+                        get_change_view_url(related.opts.app_label,
+                                            related.opts.object_name.lower(),
+                                            sub_obj._get_pk_val(),
+                                            admin_site,
+                                            levels_to_root),
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
         else:
@@ -109,11 +131,13 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                         [u'%s: %s' % (capfirst(related.opts.verbose_name), force_unicode(sub_obj)), []])
                 else:
                     # Display a link to the admin page.
-                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="../../../../%s/%s/%s/">%s</a>' % 
+                    nh(deleted_objects, current_depth, [mark_safe(u'%s: <a href="%s">%s</a>' %
                         (escape(capfirst(related.opts.verbose_name)),
-                        related.opts.app_label,
-                        related.opts.object_name.lower(),
-                        sub_obj._get_pk_val(),
+                        get_change_view_url(related.opts.app_label,
+                                            related.opts.object_name.lower(),
+                                            sub_obj._get_pk_val(),
+                                            admin_site,
+                                            levels_to_root),
                         escape(sub_obj))), []])
                 get_deleted_objects(deleted_objects, perms_needed, user, sub_obj, related.opts, current_depth+2, admin_site)
             # If there were related objects, and the user doesn't have
@@ -147,11 +171,52 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
                     # Display a link to the admin page.
                     nh(deleted_objects, current_depth, [
                         mark_safe((_('One or more %(fieldname)s in %(name)s:') % {'fieldname': escape(force_unicode(related.field.verbose_name)), 'name': escape(force_unicode(related.opts.verbose_name))}) + \
-                        (u' <a href="../../../../%s/%s/%s/">%s</a>' % \
-                            (related.opts.app_label, related.opts.module_name, sub_obj._get_pk_val(), escape(sub_obj)))), []])
+                        (u' <a href="%s">%s</a>' % \
+                            (get_change_view_url(related.opts.app_label,
+                                                 related.opts.object_name.lower(),
+                                                 sub_obj._get_pk_val(),
+                                                 admin_site,
+                                                 levels_to_root),
+                            escape(sub_obj)))), []])
         # If there were related objects, and the user doesn't have
         # permission to change them, add the missing perm to perms_needed.
         if has_admin and has_related_objs:
             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
             if not user.has_perm(p):
                 perms_needed.add(related.opts.verbose_name)
+
+def model_format_dict(obj):
+    """
+    Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
+    typically for use with string formatting.
+
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+
+    """
+    if isinstance(obj, (models.Model, models.base.ModelBase)):
+        opts = obj._meta
+    elif isinstance(obj, models.query.QuerySet):
+        opts = obj.model._meta
+    else:
+        opts = obj
+    return {
+        'verbose_name': force_unicode(opts.verbose_name),
+        'verbose_name_plural': force_unicode(opts.verbose_name_plural)
+    }
+
+def model_ngettext(obj, n=None):
+    """
+    Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
+    depending on the count `n`.
+
+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
+    If `obj` is a `QuerySet` instance, `n` is optional and the length of the
+    `QuerySet` is used.
+
+    """
+    if isinstance(obj, models.query.QuerySet):
+        if n is None:
+            n = obj.count()
+        obj = obj.model
+    d = model_format_dict(obj)
+    return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
diff --git a/tests/regressiontests/admin_registration/models.py b/tests/regressiontests/admin_registration/models.py
index fdfa369..35cf8af 100644
--- a/tests/regressiontests/admin_registration/models.py
+++ b/tests/regressiontests/admin_registration/models.py
@@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
 >>> site._registry[Person].search_fields
 ['name']
 >>> site._registry[Person].list_display
-['__str__']
+['action_checkbox', '__str__']
 >>> site._registry[Person].save_on_top
 True
 
diff --git a/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
new file mode 100644
index 0000000..316e750
--- /dev/null
+++ b/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>
diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
index eeaf039..e5e112f 100644
--- a/tests/regressiontests/admin_views/models.py
+++ b/tests/regressiontests/admin_views/models.py
@@ -1,6 +1,7 @@
 # -*- coding: utf-8 -*-
 from django.db import models
 from django.contrib import admin
+from django.core.mail import EmailMessage
 
 class Section(models.Model):
     """
@@ -199,6 +200,41 @@ class PersonaAdmin(admin.ModelAdmin):
         BarAccountAdmin
     )
 
+class Subscriber(models.Model):
+    name = models.CharField(blank=False, max_length=80)
+    email = models.EmailField(blank=False, max_length=175)
+
+    def __unicode__(self):
+        return "%s (%s)" % (self.name, self.email)
+
+class SubscriberAdmin(admin.ModelAdmin):
+    actions = ['delete_selected', 'mail_admin']
+
+    def mail_admin(self, request, selected):
+        EmailMessage(
+            'Greetings from a ModelAdmin action',
+            'This is the test email from a admin action',
+            'from@example.com',
+            ['to@example.com']
+        ).send()
+
+class ExternalSubscriber(Subscriber):
+    pass
+
+def external_mail(request, selected):
+    EmailMessage(
+        'Greetings from a function action',
+        'This is the test email from a function action',
+        'from@example.com',
+        ['to@example.com']
+    ).send()
+
+def redirect_to(request, selected):
+    from django.http import HttpResponseRedirect
+    return HttpResponseRedirect('/some-where-else/')
+
+class ExternalSubscriberAdmin(admin.ModelAdmin):
+    actions = [external_mail, redirect_to]
 
 admin.site.register(Article, ArticleAdmin)
 admin.site.register(CustomArticle, CustomArticleAdmin)
@@ -208,6 +244,8 @@ admin.site.register(Color)
 admin.site.register(Thing, ThingAdmin)
 admin.site.register(Person, PersonAdmin)
 admin.site.register(Persona, PersonaAdmin)
+admin.site.register(Subscriber, SubscriberAdmin)
+admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
 
 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
 # That way we cover all four cases:
diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
index 33000d4..49e0628 100644
--- a/tests/regressiontests/admin_views/tests.py
+++ b/tests/regressiontests/admin_views/tests.py
@@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
 from django.contrib.admin.models import LogEntry
 from django.contrib.admin.sites import LOGIN_FORM_KEY
 from django.contrib.admin.util import quote
+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.utils.html import escape
 
 # local test models
-from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, ExternalSubscriber
 
 try:
     set
@@ -516,7 +517,7 @@ class AdminViewStringPrimaryKeyTest(TestCase):
     def test_changelist_to_changeform_link(self):
         "The link from the changelist referring to the changeform of the object should be quoted"
         response = self.client.get('/test_admin/admin/admin_views/modelwithstringprimarykey/')
-        should_contain = """<tr class="row1"><th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
+        should_contain = """<th><a href="%s/">%s</a></th></tr>""" % (quote(self.pk), escape(self.pk))
         self.assertContains(response, should_contain)
 
     def test_recentactions_link(self):
@@ -743,12 +744,13 @@ class AdminViewListEditable(TestCase):
         response = self.client.get('/test_admin/admin/admin_views/person/')
         # 2 inputs per object(the field and the hidden id field) = 6
         # 2 management hidden fields = 2
+        # 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
         # main form submit button = 1
         # search field and search submit button = 2
         # 6 + 2 + 1 + 2 = 11 inputs
-        self.failUnlessEqual(response.content.count("<input"), 11)
+        self.failUnlessEqual(response.content.count("<input"), 15)
         # 1 select per object = 3 selects
-        self.failUnlessEqual(response.content.count("<select"), 3)
+        self.failUnlessEqual(response.content.count("<select"), 4)
     
     def test_post_submission(self):
         data = {
@@ -875,3 +877,64 @@ class AdminInheritedInlinesTest(TestCase):
         self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
         self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
         self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
+
+from django.core import mail
+
+class AdminActionsTest(TestCase):
+    fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
+
+    def setUp(self):
+        self.client.login(username='super', password='secret')
+
+    def tearDown(self):
+        self.client.logout()
+
+    def test_model_admin_custom_action(self):
+        "Tests a custom action defined in a ModelAdmin method"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1],
+            'action' : 'mail_admin',
+            'index': 0,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+        self.assertEquals(len(mail.outbox), 1)
+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
+
+    def test_model_admin_default_delete_action(self):
+        "Tests the default delete action defined as a ModelAdmin method"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1, 2],
+            'action' : 'delete_selected',
+            'index': 0,
+        }
+        delete_confirmation_data = {
+            ACTION_CHECKBOX_NAME: [1, 2],
+            'action' : 'delete_selected',
+            'index': 0,
+            'post': 'yes',
+        }
+        confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+        self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
+        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)
