diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py
index aaa2e30..335a44e 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()
+
+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..ece2e7a 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,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):
@@ -237,6 +252,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 +382,63 @@ 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, blank_choice=BLANK_CHOICE_DASH):
+        if hasattr(self, '_action_choices'):
+            return self._action_choices
+        self._action_choices = blank_choice
+        for action in getattr(self, 'actions', []):
+            func, name, description = self.get_action(action)
+            choice = (name, description % model_format_dict(self.opts))
+            self._action_choices.append(choice)
+        return self._action_choices
+    action_choices = property(_get_action_choices)
+    
+    def get_action(self, name_or_callable):
+        if callable(name_or_callable):
+            func = name_or_callable
+            name = name_or_callable.__name__
+        else:
+            try:
+                func = getattr(self, name_or_callable)
+            except AttributeError:
+                try:
+                    func = getattr(self.model, name_or_callable)
+                except AttributeError:
+                    raise AttributeError, \
+                        "'%s' model or '%s' objects have no action '%s'" % \
+                            (self.opts.object_name, self.__class__, name_or_callable)
+            name = name_or_callable
+        if hasattr(func, 'short_description'):
+            description = func.short_description
+        else:
+            description = capfirst(name.replace('_', ' '))
+        return func, name, description
+    
+    def delete_selected(self, request, changelist):
+        """
+        Default action which deletes the selected objects.
+        """
+        if self.has_delete_permission(request):
+            selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
+            objs = changelist.get_query_set().filter(pk__in=selected)
+            n = objs.count()
+            if n:
+                for obj in objs:
+                    obj_display = force_unicode(obj)
+                    self.log_deletion(request, obj, obj_display)
+                objs.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,38 @@ 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.action_choices
+            
+            if action_form.is_valid():
+                action = action_form.cleaned_data['action']
+                action_func, name, description = self.get_action(action)
+                response = None
+                if callable(action_func):
+                    response = action_func(request, changelist)
+                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.action_choices
+        return action_form
     
     def add_view(self, request, form_url='', extra_context=None):
         "The 'add' admin view for this model."
@@ -696,13 +802,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..21c0198
--- /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>{% trans "Action:" %} {{ 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)
