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..6294a28 100644
--- a/django/contrib/admin/media/css/changelists.css
+++ b/django/contrib/admin/media/css/changelists.css
@@ -50,6 +50,7 @@
 
 #changelist table thead th {
     white-space: nowrap;
+    vertical-align: middle;
 }
 
 #changelist table tbody td {
@@ -209,3 +210,47 @@
     border-color: #036;
 }
 
+.filtered #action-form .actions {
+    margin-right: 160px !important;
+    border-right: 1px solid #ddd;
+}
+
+#action-form .actions {
+    color: #666;
+    padding: 3px;
+    font-weight: bold;
+    background: #efefef url(../img/admin/nav-bg.gif);
+}
+
+#action-form .actions:last-child {
+    border-bottom: none;
+}
+
+#action-form .actions select {
+    border: 1px solid #aaa;
+    margin: 0 0.5em;
+}
+
+#action-toggle {
+    display: none;
+}
+
+#action-form .actions label {
+    font-size: 11px;
+    margin-left: 0.5em;
+}
+
+#action-form tbody tr input.action-select {
+    margin: 0;
+}
+
+#action-form thead th:first-child {
+    width: 1.5em;
+    text-align: center;
+}
+
+#action-form tbody td:first-child {
+    border-left: 0;
+    border-right: 1px solid #ddd;
+    text-align: center;
+}
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 859229e..4e1df38 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
@@ -15,7 +16,7 @@ from django.utils.html import escape
 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 ugettext as _, ugettext_lazy
 from django.utils.encoding import force_unicode
 try:
     set
@@ -172,7 +173,7 @@ 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
@@ -190,6 +191,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
@@ -198,6 +205,18 @@ 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
+        self.callable_actions = {}
+        for action in getattr(self, 'actions', []):
+            if callable(action):
+                self.callable_actions[action.__name__] = action
         super(ModelAdmin, self).__init__()
         
     def get_urls(self):
@@ -237,6 +256,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():
@@ -365,6 +386,59 @@ 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_action_choices(self, default_choices=BLANK_CHOICE_DASH):
+        choices = [] + default_choices
+        for action in getattr(self, 'actions', []):
+            func, name, description, instance_action = self.get_action(action)
+            choice = (name, description % model_format_dict(self.opts))
+            choices.append(choice)
+        return choices
+
+    def get_action(self, action):
+        is_instance_action = False
+        if callable(action):
+            func = action
+            action = action.__name__
+        elif hasattr(self, action):
+            func = getattr(self, action)
+        elif hasattr(self.model, action):
+            func = getattr(self.model, action)
+            is_instance_action = True
+        else:
+            if action in [name for name in self.callable_actions]:
+                return self.get_action(self.callable_actions[action])
+            raise AttributeError, \
+                "'%s' model or '%s' have no action '%s'" % \
+                    (self.opts.object_name, self.__class__.__name__, action)
+        if hasattr(func, 'short_description'):
+            description = func.short_description
+        else:
+            description = capfirst(action.replace('_', ' '))
+        return func, action, description, is_instance_action
+    
+    def delete_selected(self, request, selected):
+        """
+        Default action which deletes the selected objects.
+        """
+        if self.has_delete_permission(request):
+            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)
+                ))
+    delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
     
     def construct_change_message(self, request, form, formsets):
         """
@@ -503,6 +577,44 @@ class ModelAdmin(BaseModelAdmin):
         else:
             self.message_user(request, msg)
             return HttpResponseRedirect("../")
+
+    def response_action(self, request, changelist):
+        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()
+            
+            if action_form.is_valid():
+                action = action_form.cleaned_data['action']
+                func, name, description, instance_action = self.get_action(action)
+                selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+                results = changelist.get_query_set().filter(pk__in=selected)
+                response = None
+                if callable(func):
+                    if instance_action:
+                        for obj in results:
+                            getattr(obj, name)(request)
+                    else:
+                        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()
+        return action_form
     
     def add_view(self, request, form_url='', extra_context=None):
         "The 'add' admin view for this model."
@@ -696,13 +808,21 @@ class ModelAdmin(BaseModelAdmin):
                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
         
+        action_response = self.response_action(request, cl)
+        if isinstance(action_response, HttpResponse):
+            return action_response
+        
         context = {
             'title': cl.title,
             'is_popup': cl.is_popup,
             'cl': cl,
+            'media': mark_safe(self.media),
             'has_add_permission': self.has_add_permission(request),
             'root_path': self.admin_site.root_path,
             'app_label': app_label,
+            'action_form': action_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/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 5f8a430..1f00318 100644
--- a/django/contrib/admin/templates/admin/change_list.html
+++ b/django/contrib/admin/templates/admin/change_list.html
@@ -3,6 +3,8 @@
 
 {% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />{% endblock %}
 
+{% block extrahead %}{{ block.super }}{{ media }}{% endblock %}
+
 {% block bodyclass %}change-list{% endblock %}
 
 {% if not is_popup %}{% block breadcrumbs %}<div class="breadcrumbs"><a href="../../">{% trans "Home" %}</a> &rsaquo; <a href="../">{{ app_label|capfirst }}</a> &rsaquo; {{ cl.opts.verbose_name_plural|capfirst }}</div>{% endblock %}{% endif %}
@@ -31,7 +33,13 @@
 {% endif %}
 {% endblock %}
 
-{% block result_list %}{% result_list cl %}{% endblock %}
+{% block result_list %}
+<form id="action-form" method="post" action="">
+    {% 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 %}
+</form>
+{% endblock %}
 {% block pagination %}{% pagination cl %}{% endblock %}
 </div>
 </div>
diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
index 37cdb91..59fc4ff 100644
--- a/django/contrib/admin/templatetags/admin_list.py
+++ b/django/contrib/admin/templatetags/admin_list.py
@@ -311,3 +311,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..7c2b56b 100644
--- a/django/contrib/admin/util.py
+++ b/django/contrib/admin/util.py
@@ -4,7 +4,7 @@ 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 _
 
 def quote(s):
     """
@@ -155,3 +155,39 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
             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..1f6cc7f
--- /dev/null
+++ b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
@@ -0,0 +1,20 @@
+<?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.directsubscriber">
+        <field type="CharField" name="name">John Doe</field>
+        <field type="CharField" name="email">john@example.org</field>
+        <field type="BooleanField" name="paid">True</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>
\ No newline at end of file
diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
index d849a7b..fa4faa3 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):
     """
@@ -177,6 +178,56 @@ 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 DirectSubscriber(Subscriber):
+    paid = models.BooleanField(default=False)
+
+    def direct_mail(self, request):
+        EmailMessage(
+            'Greetings from a model action',
+            'This is the test email from a model action',
+            'from@example.com',
+            [self.email]
+        ).send()
+
+class DirectSubscriberAdmin(admin.ModelAdmin):
+    actions = ['direct_mail']
+
+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)
 admin.site.register(Section, inlines=[ArticleInline])
@@ -184,6 +235,9 @@ admin.site.register(ModelWithStringPrimaryKey)
 admin.site.register(Color)
 admin.site.register(Thing, ThingAdmin)
 admin.site.register(Persona, PersonaAdmin)
+admin.site.register(Subscriber, SubscriberAdmin)
+admin.site.register(DirectSubscriber, DirectSubscriberAdmin)
+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 bf198bc..612e1ec 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, Persona, FooAccount, BarAccount
+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Persona, FooAccount, BarAccount, Subscriber, DirectSubscriber, ExternalSubscriber
 
 try:
     set
@@ -805,3 +806,69 @@ 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,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
+        self.failUnlessEqual(Subscriber.objects.count(), 0)
+
+    def test_custom_model_instance_action(self):
+        "Tests a custom action defined in a model method"
+        action_data = {
+            ACTION_CHECKBOX_NAME: [1],
+            'action' : 'direct_mail',
+            'index': 0,
+            'paid': 1,
+        }
+        response = self.client.post('/test_admin/admin/admin_views/directsubscriber/', action_data)
+        self.assertEquals(len(mail.outbox), 1)
+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a model action')
+        self.assertEquals(mail.outbox[0].to, [u'john@example.org'])
+
+    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)
