Code

Ticket #10505: admin-actions.6.diff

File admin-actions.6.diff, 29.9 KB (added by jezdez, 5 years ago)

Updated to work with merged list_editable code. Now only passes the queryset to response_action method and fixes some stylesheet issues.

Line 
1diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py
2index aaa2e30..aac8911 100644
3--- a/django/contrib/admin/helpers.py
4+++ b/django/contrib/admin/helpers.py
5@@ -6,6 +6,14 @@ from django.utils.safestring import mark_safe
6 from django.utils.encoding import force_unicode
7 from django.contrib.admin.util import flatten_fieldsets
8 from django.contrib.contenttypes.models import ContentType
9+from django.utils.translation import ugettext_lazy as _
10+
11+ACTION_CHECKBOX_NAME = 'selected'
12+
13+class ActionForm(forms.Form):
14+    action = forms.ChoiceField(label=_('Action:'))
15+
16+checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
17 
18 class AdminForm(object):
19     def __init__(self, form, fieldsets, prepopulated_fields):
20diff --git a/django/contrib/admin/media/css/changelists.css b/django/contrib/admin/media/css/changelists.css
21index 40142f5..649cff7 100644
22--- a/django/contrib/admin/media/css/changelists.css
23+++ b/django/contrib/admin/media/css/changelists.css
24@@ -50,12 +50,24 @@
25 
26 #changelist table thead th {
27     white-space: nowrap;
28+    vertical-align: middle;
29+}
30+
31+#changelist table thead th:first-child {
32+    width: 1.5em;
33+    text-align: center;
34 }
35 
36 #changelist table tbody td {
37     border-left: 1px solid #ddd;
38 }
39 
40+#changelist table tbody td:first-child {
41+    border-left: 0;
42+    border-right: 1px solid #ddd;
43+    text-align: center;
44+}
45+
46 #changelist table tfoot {
47     color: #666;
48 }
49@@ -209,3 +221,35 @@
50     border-color: #036;
51 }
52 
53+/* ACTIONS */
54+
55+.filtered .actions {
56+    margin-right: 160px !important;
57+    border-right: 1px solid #ddd;
58+}
59+
60+#changelist .actions {
61+    color: #666;
62+    padding: 3px;
63+    border-bottom: 1px solid #ddd;
64+    background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x;
65+}
66+
67+#changelist .actions:last-child {
68+    border-bottom: none;
69+}
70+
71+#changelist .actions select {
72+    border: 1px solid #aaa;
73+    margin: 0 0.5em;
74+    padding: 1px 2px;
75+}
76+
77+#changelist .actions label {
78+    font-size: 11px;
79+    margin: 0 0.5em;
80+}
81+
82+#changelist #action-toggle {
83+    display: none;
84+}
85diff --git a/django/contrib/admin/media/js/actions.js b/django/contrib/admin/media/js/actions.js
86new file mode 100644
87index 0000000..febb0c1
88--- /dev/null
89+++ b/django/contrib/admin/media/js/actions.js
90@@ -0,0 +1,19 @@
91+var Actions = {
92+    init: function() {
93+        selectAll = document.getElementById('action-toggle');
94+        if (selectAll) {
95+            selectAll.style.display = 'inline';
96+            addEvent(selectAll, 'change', function() {
97+                Actions.checker(this.checked);
98+            });
99+        }
100+    },
101+    checker: function(checked) {
102+        actionCheckboxes = document.getElementsBySelector('tr input.action-select');
103+        for(var i = 0; i < actionCheckboxes.length; i++) {
104+            actionCheckboxes[i].checked = checked;
105+        }
106+    }
107+}
108+
109+addEvent(window, 'load', Actions.init);
110diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
111index c79523c..ef2c52d 100644
112--- a/django/contrib/admin/options.py
113+++ b/django/contrib/admin/options.py
114@@ -5,9 +5,10 @@ from django.forms.models import BaseInlineFormSet
115 from django.contrib.contenttypes.models import ContentType
116 from django.contrib.admin import widgets
117 from django.contrib.admin import helpers
118-from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
119+from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
120 from django.core.exceptions import PermissionDenied
121 from django.db import models, transaction
122+from django.db.models.fields import BLANK_CHOICE_DASH
123 from django.http import Http404, HttpResponse, HttpResponseRedirect
124 from django.shortcuts import get_object_or_404, render_to_response
125 from django.utils.functional import update_wrapper
126@@ -16,7 +17,7 @@ from django.utils.safestring import mark_safe
127 from django.utils.functional import curry
128 from django.utils.text import capfirst, get_text_list
129 from django.utils.translation import ugettext as _
130-from django.utils.translation import ngettext
131+from django.utils.translation import ngettext, ugettext_lazy
132 from django.utils.encoding import force_unicode
133 try:
134     set
135@@ -173,7 +174,7 @@ class ModelAdmin(BaseModelAdmin):
136     "Encapsulates all admin options and functionality for a given model."
137     __metaclass__ = forms.MediaDefiningClass
138     
139-    list_display = ('__str__',)
140+    list_display = ('action_checkbox', '__str__',)
141     list_display_links = ()
142     list_filter = ()
143     list_select_related = False
144@@ -192,6 +193,12 @@ class ModelAdmin(BaseModelAdmin):
145     delete_confirmation_template = None
146     object_history_template = None
147     
148+    # Actions
149+    actions = ['delete_selected']
150+    action_form = helpers.ActionForm
151+    actions_on_top = False
152+    actions_on_bottom = True
153+   
154     def __init__(self, model, admin_site):
155         self.model = model
156         self.opts = model._meta
157@@ -200,6 +207,14 @@ class ModelAdmin(BaseModelAdmin):
158         for inline_class in self.inlines:
159             inline_instance = inline_class(self.model, self.admin_site)
160             self.inline_instances.append(inline_instance)
161+        if 'action_checkbox' not in self.list_display:
162+            self.list_display = list(self.list_display)
163+            self.list_display.insert(0, 'action_checkbox')
164+        if not self.list_display_links:
165+            for name in self.list_display:
166+                if name != 'action_checkbox':
167+                    self.list_display_links = [name]
168+                    break
169         super(ModelAdmin, self).__init__()
170         
171     def get_urls(self):
172@@ -239,6 +254,8 @@ class ModelAdmin(BaseModelAdmin):
173         from django.conf import settings
174         
175         js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
176+        if self.actions:
177+            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
178         if self.prepopulated_fields:
179             js.append('js/urlify.js')
180         if self.opts.get_ordered_objects():
181@@ -390,6 +407,105 @@ class ModelAdmin(BaseModelAdmin):
182             action_flag     = DELETION
183         )
184     
185+    def action_checkbox(self, obj):
186+        """
187+        A list_display column containing a checkbox widget.
188+        """
189+        return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
190+    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
191+    action_checkbox.allow_tags = True
192+   
193+    def get_action_choices(self, default_choices=BLANK_CHOICE_DASH):
194+        choices = [] + default_choices
195+        for action in getattr(self, 'actions', []):
196+            func, name, description, instance_action = self.get_action(action)
197+            choice = (name, description % model_format_dict(self.opts))
198+            choices.append(choice)
199+        return choices
200+
201+    def get_action(self, action):
202+        is_instance_action = False
203+        if callable(action):
204+            func = action
205+            action = action.__name__
206+        elif hasattr(self, action):
207+            func = getattr(self, action)
208+        elif hasattr(self.model, action):
209+            func = getattr(self.model, action)
210+            is_instance_action = True
211+        else:
212+            callable_actions = {}
213+            for item in getattr(self, 'actions', []):
214+                if callable(item):
215+                    callable_actions[item.__name__] = item
216+            if action in callable_actions:
217+                return self.get_action(callable_actions[action])
218+            raise AttributeError, \
219+                "'%s' model or '%s' have no action '%s'" % \
220+                    (self.opts.object_name, self.__class__.__name__, action)
221+        if hasattr(func, 'short_description'):
222+            description = func.short_description
223+        else:
224+            description = capfirst(action.replace('_', ' '))
225+        return func, action, description, is_instance_action
226+   
227+    def delete_selected(self, request, selected):
228+        """
229+        Default action which deletes the selected objects.
230+        """
231+        opts = self.model._meta
232+        app_label = opts.app_label
233+       
234+        if not self.has_delete_permission(request):
235+            raise PermissionDenied
236+       
237+        # Populate deleted_objects, a data structure of all related objects that
238+        # will also be deleted.
239+       
240+        # deleted_objects must be a list if we want to use '|unordered_list' in the template
241+        deleted_objects = []
242+        for obj in selected:
243+            deleted_objects.append([mark_safe(u'%s: <a href="%s/">%s</a>' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []])
244+        perms_needed = set()
245+        i = 0
246+        for d in deleted_objects:
247+            # FIXME: the urlpath to the detail-view of the related objects are hardcoded as "../../../../"
248+            # which is wrong from this changelist_view. Is there a admin-reverse-urlconf refactor?
249+            get_deleted_objects(deleted_objects[i], perms_needed, request.user, selected[i], opts, 1, self.admin_site)
250+            i=i+1
251+       
252+        # The user has already confirmed the deletion.
253+        if request.POST.get('post'):
254+            if perms_needed:
255+                raise PermissionDenied
256+            n = selected.count()
257+            if n:
258+                for obj in selected:
259+                    obj_display = force_unicode(obj)
260+                    self.log_deletion(request, obj, obj_display)
261+                selected.delete()
262+                self.message_user(request, _("Successfully deleted %d %s.") % (
263+                    n, model_ngettext(self.opts, n)
264+                ))
265+            return None
266+       
267+        context = {
268+            "title": _("Are you sure?"),
269+            "object_name": force_unicode(opts.verbose_name),
270+            "deleted_objects": deleted_objects,
271+            'selected': selected,
272+            "perms_lacking": perms_needed,
273+            "opts": opts,
274+            "root_path": self.admin_site.root_path,
275+            "app_label": app_label,
276+        }
277+        return render_to_response(self.delete_confirmation_template or [
278+            "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()),
279+            "admin/%s/delete_selected_confirmation.html" % app_label,
280+            "admin/delete_selected_confirmation.html"
281+        ], context, context_instance=template.RequestContext(request))
282+   
283+    delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
284     
285     def construct_change_message(self, request, form, formsets):
286         """
287@@ -528,6 +644,44 @@ class ModelAdmin(BaseModelAdmin):
288         else:
289             self.message_user(request, msg)
290             return HttpResponseRedirect("../")
291+
292+    def response_action(self, request, queryset):
293+        if request.method == 'POST':
294+            # There can be multiple action forms on the page (at the top
295+            # and bottom of the change list, for example). Get the action
296+            # whose button was pushed.
297+            try:
298+                action_index = int(request.POST.get('index', 0))
299+            except ValueError:
300+                action_index = 0
301+            data = {}
302+            for key in request.POST:
303+                if key not in (helpers.ACTION_CHECKBOX_NAME, 'index'):
304+                    data[key] = request.POST.getlist(key)[action_index]
305+            action_form = self.action_form(data, auto_id=None)
306+            action_form.fields['action'].choices = self.get_action_choices()
307+           
308+            if action_form.is_valid():
309+                action = action_form.cleaned_data['action']
310+                func, name, description, instance_action = self.get_action(action)
311+                selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
312+                results = queryset.filter(pk__in=selected)
313+                response = None
314+                if callable(func):
315+                    if instance_action:
316+                        for obj in results:
317+                            getattr(obj, name)(request)
318+                    else:
319+                        response = func(request, results)
320+                if isinstance(response, HttpResponse):
321+                    return response
322+                else:
323+                    redirect_to = request.META.get('HTTP_REFERER') or "."
324+                    return HttpResponseRedirect(redirect_to)
325+        else:
326+            action_form = self.action_form(auto_id=None)
327+            action_form.fields['action'].choices = self.get_action_choices()
328+        return action_form
329     
330     def add_view(self, request, form_url='', extra_context=None):
331         "The 'add' admin view for this model."
332@@ -721,6 +875,10 @@ class ModelAdmin(BaseModelAdmin):
333                 return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
334             return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
335         
336+        action_form_or_response = self.response_action(request, queryset=cl.get_query_set())
337+        if isinstance(action_form_or_response, HttpResponse):
338+            return action_form_or_response
339+       
340         # If we're allowing changelist editing, we need to construct a formset
341         # for the changelist given all the fields to be edited. Then we'll
342         # use the formset to validate/process POSTed data.
343@@ -761,8 +919,8 @@ class ModelAdmin(BaseModelAdmin):
344         if formset:
345             media = self.media + formset.media
346         else:
347-            media = None
348-
349+            media = self.media
350+       
351         context = {
352             'title': cl.title,
353             'is_popup': cl.is_popup,
354@@ -771,6 +929,9 @@ class ModelAdmin(BaseModelAdmin):
355             'has_add_permission': self.has_add_permission(request),
356             'root_path': self.admin_site.root_path,
357             'app_label': app_label,
358+            'action_form': action_form_or_response,
359+            'actions_on_top': self.actions_on_top,
360+            'actions_on_bottom': self.actions_on_bottom,
361         }
362         context.update(extra_context or {})
363         return render_to_response(self.change_list_template or [
364diff --git a/django/contrib/admin/templates/admin/actions.html b/django/contrib/admin/templates/admin/actions.html
365new file mode 100644
366index 0000000..bf4b975
367--- /dev/null
368+++ b/django/contrib/admin/templates/admin/actions.html
369@@ -0,0 +1,5 @@
370+{% load i18n %}
371+<div class="actions">
372+    {% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
373+    <button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
374+</div>
375diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html
376index dca5b80..63254b8 100644
377--- a/django/contrib/admin/templates/admin/change_list.html
378+++ b/django/contrib/admin/templates/admin/change_list.html
379@@ -7,8 +7,8 @@
380   {% if cl.formset %}
381     <link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/forms.css" />
382     <script type="text/javascript" src="../../jsi18n/"></script>
383-    {{ media }}
384   {% endif %}
385+  {{ media }}
386 {% endblock %}
387 
388 {% block bodyclass %}change-list{% endblock %}
389@@ -63,14 +63,18 @@
390         {% endif %}
391       {% endblock %}
392       
393+      <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
394       {% if cl.formset %}
395-        <form action="" method="post"{% if cl.formset.is_multipart %} enctype="multipart/form-data"{% endif %}>
396         {{ cl.formset.management_form }}
397       {% endif %}
398 
399-      {% block result_list %}{% result_list cl %}{% endblock %}
400+      {% block result_list %}
401+          {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
402+          {% result_list cl %}
403+          {% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
404+      {% endblock %}
405       {% block pagination %}{% pagination cl %}{% endblock %}
406-      {% if cl.formset %}</form>{% endif %}
407+      </form>
408     </div>
409   </div>
410 {% endblock %}
411diff --git a/django/contrib/admin/templates/admin/delete_selected_confirmation.html b/django/contrib/admin/templates/admin/delete_selected_confirmation.html
412new file mode 100644
413index 0000000..183134d
414--- /dev/null
415+++ b/django/contrib/admin/templates/admin/delete_selected_confirmation.html
416@@ -0,0 +1,37 @@
417+{% extends "admin/base_site.html" %}
418+{% load i18n %}
419+
420+{% block breadcrumbs %}
421+<div class="breadcrumbs">
422+     <a href="../../">{% trans "Home" %}</a> &rsaquo;
423+     <a href="../">{{ app_label|capfirst }}</a> &rsaquo;
424+     <a href="./">{{ opts.verbose_name_plural|capfirst }}</a> &rsaquo;
425+     {% trans 'Delete multiple objects' %}
426+</div>
427+{% endblock %}
428+
429+{% block content %}
430+{% if perms_lacking %}
431+    <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>
432+    <ul>
433+    {% for obj in perms_lacking %}
434+        <li>{{ obj }}</li>
435+    {% endfor %}
436+    </ul>
437+{% else %}
438+    <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>
439+    {% for d in deleted_objects %}
440+        <ul>{{ d|unordered_list }}</ul>
441+    {% endfor %}
442+    <form action="" method="post">
443+    <div>
444+    {% for s in selected %}
445+    <input type="hidden" name="selected" value="{{ s.pk }}" />
446+    {% endfor %}
447+    <input type="hidden" name="action" value="delete_selected" />
448+    <input type="hidden" name="post" value="yes" />
449+    <input type="submit" value="{% trans "Yes, I'm sure" %}" />
450+    </div>
451+    </form>
452+{% endif %}
453+{% endblock %}
454\ No newline at end of file
455diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
456index 063ef0e..a374bf5 100644
457--- a/django/contrib/admin/templatetags/admin_list.py
458+++ b/django/contrib/admin/templatetags/admin_list.py
459@@ -325,3 +325,12 @@ search_form = register.inclusion_tag('admin/search_form.html')(search_form)
460 def admin_list_filter(cl, spec):
461     return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
462 admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
463+
464+def admin_actions(context):
465+    """
466+    Track the number of times the action field has been rendered on the page,
467+    so we know which value to use.
468+    """
469+    context['action_index'] = context.get('action_index', -1) + 1
470+    return context
471+admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
472diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
473index 4164c8a..7c2b56b 100644
474--- a/django/contrib/admin/util.py
475+++ b/django/contrib/admin/util.py
476@@ -4,7 +4,7 @@ from django.utils.html import escape
477 from django.utils.safestring import mark_safe
478 from django.utils.text import capfirst
479 from django.utils.encoding import force_unicode
480-from django.utils.translation import ugettext as _
481+from django.utils.translation import ungettext, ugettext as _
482 
483 def quote(s):
484     """
485@@ -155,3 +155,39 @@ def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_
486             p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
487             if not user.has_perm(p):
488                 perms_needed.add(related.opts.verbose_name)
489+
490+def model_format_dict(obj):
491+    """
492+    Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
493+    typically for use with string formatting.
494+
495+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
496+
497+    """
498+    if isinstance(obj, (models.Model, models.base.ModelBase)):
499+        opts = obj._meta
500+    elif isinstance(obj, models.query.QuerySet):
501+        opts = obj.model._meta
502+    else:
503+        opts = obj
504+    return {
505+        'verbose_name': force_unicode(opts.verbose_name),
506+        'verbose_name_plural': force_unicode(opts.verbose_name_plural)
507+    }
508+
509+def model_ngettext(obj, n=None):
510+    """
511+    Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
512+    depending on the count `n`.
513+
514+    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
515+    If `obj` is a `QuerySet` instance, `n` is optional and the length of the
516+    `QuerySet` is used.
517+
518+    """
519+    if isinstance(obj, models.query.QuerySet):
520+        if n is None:
521+            n = obj.count()
522+        obj = obj.model
523+    d = model_format_dict(obj)
524+    return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
525diff --git a/tests/regressiontests/admin_registration/models.py b/tests/regressiontests/admin_registration/models.py
526index fdfa369..35cf8af 100644
527--- a/tests/regressiontests/admin_registration/models.py
528+++ b/tests/regressiontests/admin_registration/models.py
529@@ -49,7 +49,7 @@ AlreadyRegistered: The model Person is already registered
530 >>> site._registry[Person].search_fields
531 ['name']
532 >>> site._registry[Person].list_display
533-['__str__']
534+['action_checkbox', '__str__']
535 >>> site._registry[Person].save_on_top
536 True
537 
538diff --git a/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
539new file mode 100644
540index 0000000..1f6cc7f
541--- /dev/null
542+++ b/tests/regressiontests/admin_views/fixtures/admin-views-actions.xml
543@@ -0,0 +1,20 @@
544+<?xml version="1.0" encoding="utf-8"?>
545+<django-objects version="1.0">
546+    <object pk="1" model="admin_views.subscriber">
547+        <field type="CharField" name="name">John Doe</field>
548+        <field type="CharField" name="email">john@example.org</field>
549+    </object>
550+    <object pk="2" model="admin_views.subscriber">
551+        <field type="CharField" name="name">Max Mustermann</field>
552+        <field type="CharField" name="email">max@example.org</field>
553+    </object>
554+    <object pk="1" model="admin_views.directsubscriber">
555+        <field type="CharField" name="name">John Doe</field>
556+        <field type="CharField" name="email">john@example.org</field>
557+        <field type="BooleanField" name="paid">True</field>
558+    </object>
559+    <object pk="1" model="admin_views.externalsubscriber">
560+        <field type="CharField" name="name">John Doe</field>
561+        <field type="CharField" name="email">john@example.org</field>
562+    </object>
563+</django-objects>
564\ No newline at end of file
565diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
566index eeaf039..0f53230 100644
567--- a/tests/regressiontests/admin_views/models.py
568+++ b/tests/regressiontests/admin_views/models.py
569@@ -1,6 +1,7 @@
570 # -*- coding: utf-8 -*-
571 from django.db import models
572 from django.contrib import admin
573+from django.core.mail import EmailMessage
574 
575 class Section(models.Model):
576     """
577@@ -199,6 +200,55 @@ class PersonaAdmin(admin.ModelAdmin):
578         BarAccountAdmin
579     )
580 
581+class Subscriber(models.Model):
582+    name = models.CharField(blank=False, max_length=80)
583+    email = models.EmailField(blank=False, max_length=175)
584+
585+    def __unicode__(self):
586+        return "%s (%s)" % (self.name, self.email)
587+
588+class SubscriberAdmin(admin.ModelAdmin):
589+    actions = ['delete_selected', 'mail_admin']
590+
591+    def mail_admin(self, request, selected):
592+        EmailMessage(
593+            'Greetings from a ModelAdmin action',
594+            'This is the test email from a admin action',
595+            'from@example.com',
596+            ['to@example.com']
597+        ).send()
598+
599+class DirectSubscriber(Subscriber):
600+    paid = models.BooleanField(default=False)
601+
602+    def direct_mail(self, request):
603+        EmailMessage(
604+            'Greetings from a model action',
605+            'This is the test email from a model action',
606+            'from@example.com',
607+            [self.email]
608+        ).send()
609+
610+class DirectSubscriberAdmin(admin.ModelAdmin):
611+    actions = ['direct_mail']
612+
613+class ExternalSubscriber(Subscriber):
614+    pass
615+
616+def external_mail(request, selected):
617+    EmailMessage(
618+        'Greetings from a function action',
619+        'This is the test email from a function action',
620+        'from@example.com',
621+        ['to@example.com']
622+    ).send()
623+
624+def redirect_to(request, selected):
625+    from django.http import HttpResponseRedirect
626+    return HttpResponseRedirect('/some-where-else/')
627+
628+class ExternalSubscriberAdmin(admin.ModelAdmin):
629+    actions = [external_mail, redirect_to]
630 
631 admin.site.register(Article, ArticleAdmin)
632 admin.site.register(CustomArticle, CustomArticleAdmin)
633@@ -208,6 +258,9 @@ admin.site.register(Color)
634 admin.site.register(Thing, ThingAdmin)
635 admin.site.register(Person, PersonAdmin)
636 admin.site.register(Persona, PersonaAdmin)
637+admin.site.register(Subscriber, SubscriberAdmin)
638+admin.site.register(DirectSubscriber, DirectSubscriberAdmin)
639+admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
640 
641 # We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
642 # That way we cover all four cases:
643diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
644index 33000d4..8be78a7 100644
645--- a/tests/regressiontests/admin_views/tests.py
646+++ b/tests/regressiontests/admin_views/tests.py
647@@ -8,10 +8,11 @@ from django.contrib.contenttypes.models import ContentType
648 from django.contrib.admin.models import LogEntry
649 from django.contrib.admin.sites import LOGIN_FORM_KEY
650 from django.contrib.admin.util import quote
651+from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
652 from django.utils.html import escape
653 
654 # local test models
655-from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount
656+from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Person, Persona, FooAccount, BarAccount, Subscriber, DirectSubscriber, ExternalSubscriber
657 
658 try:
659     set
660@@ -743,12 +744,13 @@ class AdminViewListEditable(TestCase):
661         response = self.client.get('/test_admin/admin/admin_views/person/')
662         # 2 inputs per object(the field and the hidden id field) = 6
663         # 2 management hidden fields = 2
664+        # 4 action inputs (3 regular checkboxes, 1 checkbox to select all)
665         # main form submit button = 1
666         # search field and search submit button = 2
667         # 6 + 2 + 1 + 2 = 11 inputs
668-        self.failUnlessEqual(response.content.count("<input"), 11)
669+        self.failUnlessEqual(response.content.count("<input"), 15)
670         # 1 select per object = 3 selects
671-        self.failUnlessEqual(response.content.count("<select"), 3)
672+        self.failUnlessEqual(response.content.count("<select"), 4)
673     
674     def test_post_submission(self):
675         data = {
676@@ -875,3 +877,77 @@ class AdminInheritedInlinesTest(TestCase):
677         self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
678         self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
679         self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
680+
681+from django.core import mail
682+
683+class AdminActionsTest(TestCase):
684+    fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
685+
686+    def setUp(self):
687+        self.client.login(username='super', password='secret')
688+
689+    def tearDown(self):
690+        self.client.logout()
691+
692+    def test_model_admin_custom_action(self):
693+        "Tests a custom action defined in a ModelAdmin method"
694+        action_data = {
695+            ACTION_CHECKBOX_NAME: [1],
696+            'action' : 'mail_admin',
697+            'index': 0,
698+        }
699+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
700+        self.assertEquals(len(mail.outbox), 1)
701+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
702+
703+    def test_model_admin_default_delete_action(self):
704+        "Tests the default delete action defined as a ModelAdmin method"
705+        action_data = {
706+            ACTION_CHECKBOX_NAME: [1, 2],
707+            'action' : 'delete_selected',
708+            'index': 0,
709+        }
710+        delete_confirmation_data = {
711+            ACTION_CHECKBOX_NAME: [1, 2],
712+            'action' : 'delete_selected',
713+            'index': 0,
714+            'post': 'yes',
715+        }
716+        confirmation = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
717+        self.assertContains(confirmation, "Are you sure you want to delete the selected subscriber objects")
718+        response = self.client.post('/test_admin/admin/admin_views/subscriber/', delete_confirmation_data)
719+        self.failUnlessEqual(Subscriber.objects.count(), 0)
720+
721+    def test_custom_model_instance_action(self):
722+        "Tests a custom action defined in a model method"
723+        action_data = {
724+            ACTION_CHECKBOX_NAME: [1],
725+            'action' : 'direct_mail',
726+            'index': 0,
727+            'paid': 1,
728+        }
729+        response = self.client.post('/test_admin/admin/admin_views/directsubscriber/', action_data)
730+        self.assertEquals(len(mail.outbox), 1)
731+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a model action')
732+        self.assertEquals(mail.outbox[0].to, [u'john@example.org'])
733+
734+    def test_custom_function_mail_action(self):
735+        "Tests a custom action defined in a function"
736+        action_data = {
737+            ACTION_CHECKBOX_NAME: [1],
738+            'action' : 'external_mail',
739+            'index': 0,
740+        }
741+        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
742+        self.assertEquals(len(mail.outbox), 1)
743+        self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
744+
745+    def test_custom_function_action_with_redirect(self):
746+        "Tests a custom action defined in a function"
747+        action_data = {
748+            ACTION_CHECKBOX_NAME: [1],
749+            'action' : 'redirect_to',
750+            'index': 0,
751+        }
752+        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
753+        self.failUnlessEqual(response.status_code, 302)