Ticket #10505: admin-actions.2.diff

File admin-actions.2.diff, 23.9 KB (added by Jannis Leidel, 16 years ago)

Refactored action loading and choices. Only the queryset of the selection is passed to actions (in model admin methods and function) now. Model methods don't get the whole queryset anymore but are called each (~obj.action()). Added tests, yay!

  • django/contrib/admin/helpers.py

    diff --git a/django/contrib/admin/helpers.py b/django/contrib/admin/helpers.py
    index aaa2e30..aac8911 100644
    a b from django.utils.safestring import mark_safe  
    66from django.utils.encoding import force_unicode
    77from django.contrib.admin.util import flatten_fieldsets
    88from django.contrib.contenttypes.models import ContentType
     9from django.utils.translation import ugettext_lazy as _
     10
     11ACTION_CHECKBOX_NAME = 'selected'
     12
     13class ActionForm(forms.Form):
     14    action = forms.ChoiceField(label=_('Action:'))
     15
     16checkbox = forms.CheckboxInput({'class': 'action-select'}, lambda value: False)
    917
    1018class AdminForm(object):
    1119    def __init__(self, form, fieldsets, prepopulated_fields):
  • django/contrib/admin/media/css/changelists.css

    diff --git a/django/contrib/admin/media/css/changelists.css b/django/contrib/admin/media/css/changelists.css
    index 40142f5..6294a28 100644
    a b  
    5050
    5151#changelist table thead th {
    5252    white-space: nowrap;
     53    vertical-align: middle;
    5354}
    5455
    5556#changelist table tbody td {
     
    209210    border-color: #036;
    210211}
    211212
     213.filtered #action-form .actions {
     214    margin-right: 160px !important;
     215    border-right: 1px solid #ddd;
     216}
     217
     218#action-form .actions {
     219    color: #666;
     220    padding: 3px;
     221    font-weight: bold;
     222    background: #efefef url(../img/admin/nav-bg.gif);
     223}
     224
     225#action-form .actions:last-child {
     226    border-bottom: none;
     227}
     228
     229#action-form .actions select {
     230    border: 1px solid #aaa;
     231    margin: 0 0.5em;
     232}
     233
     234#action-toggle {
     235    display: none;
     236}
     237
     238#action-form .actions label {
     239    font-size: 11px;
     240    margin-left: 0.5em;
     241}
     242
     243#action-form tbody tr input.action-select {
     244    margin: 0;
     245}
     246
     247#action-form thead th:first-child {
     248    width: 1.5em;
     249    text-align: center;
     250}
     251
     252#action-form tbody td:first-child {
     253    border-left: 0;
     254    border-right: 1px solid #ddd;
     255    text-align: center;
     256}
  • new file django/contrib/admin/media/js/actions.js

    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
    - +  
     1var Actions = {
     2    init: function() {
     3        selectAll = document.getElementById('action-toggle');
     4        if (selectAll) {
     5            selectAll.style.display = 'inline';
     6            addEvent(selectAll, 'change', function() {
     7                Actions.checker(this.checked);
     8            });
     9        }
     10    },
     11    checker: function(checked) {
     12        actionCheckboxes = document.getElementsBySelector('tr input.action-select');
     13        for(var i = 0; i < actionCheckboxes.length; i++) {
     14            actionCheckboxes[i].checked = checked;
     15        }
     16    }
     17}
     18
     19addEvent(window, 'load', Actions.init);
  • django/contrib/admin/options.py

    diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py
    index 859229e..4e1df38 100644
    a b from django.forms.models import BaseInlineFormSet  
    55from django.contrib.contenttypes.models import ContentType
    66from django.contrib.admin import widgets
    77from django.contrib.admin import helpers
    8 from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects
     8from django.contrib.admin.util import unquote, flatten_fieldsets, get_deleted_objects, model_ngettext, model_format_dict
    99from django.core.exceptions import PermissionDenied
    1010from django.db import models, transaction
     11from django.db.models.fields import BLANK_CHOICE_DASH
    1112from django.http import Http404, HttpResponse, HttpResponseRedirect
    1213from django.shortcuts import get_object_or_404, render_to_response
    1314from django.utils.functional import update_wrapper
    from django.utils.html import escape  
    1516from django.utils.safestring import mark_safe
    1617from django.utils.functional import curry
    1718from django.utils.text import capfirst, get_text_list
    18 from django.utils.translation import ugettext as _
     19from django.utils.translation import ugettext as _, ugettext_lazy
    1920from django.utils.encoding import force_unicode
    2021try:
    2122    set
    class ModelAdmin(BaseModelAdmin):  
    172173    "Encapsulates all admin options and functionality for a given model."
    173174    __metaclass__ = forms.MediaDefiningClass
    174175   
    175     list_display = ('__str__',)
     176    list_display = ('action_checkbox', '__str__',)
    176177    list_display_links = ()
    177178    list_filter = ()
    178179    list_select_related = False
    class ModelAdmin(BaseModelAdmin):  
    190191    delete_confirmation_template = None
    191192    object_history_template = None
    192193   
     194    # Actions
     195    actions = ['delete_selected']
     196    action_form = helpers.ActionForm
     197    actions_on_top = False
     198    actions_on_bottom = True
     199   
    193200    def __init__(self, model, admin_site):
    194201        self.model = model
    195202        self.opts = model._meta
    class ModelAdmin(BaseModelAdmin):  
    198205        for inline_class in self.inlines:
    199206            inline_instance = inline_class(self.model, self.admin_site)
    200207            self.inline_instances.append(inline_instance)
     208        if 'action_checkbox' not in self.list_display:
     209            self.list_display = list(self.list_display)
     210            self.list_display.insert(0, 'action_checkbox')
     211        if not self.list_display_links:
     212            for name in self.list_display:
     213                if name != 'action_checkbox':
     214                    self.list_display_links = [name]
     215                    break
     216        self.callable_actions = {}
     217        for action in getattr(self, 'actions', []):
     218            if callable(action):
     219                self.callable_actions[action.__name__] = action
    201220        super(ModelAdmin, self).__init__()
    202221       
    203222    def get_urls(self):
    class ModelAdmin(BaseModelAdmin):  
    237256        from django.conf import settings
    238257       
    239258        js = ['js/core.js', 'js/admin/RelatedObjectLookups.js']
     259        if self.actions:
     260            js.extend(['js/getElementsBySelector.js', 'js/actions.js'])
    240261        if self.prepopulated_fields:
    241262            js.append('js/urlify.js')
    242263        if self.opts.get_ordered_objects():
    class ModelAdmin(BaseModelAdmin):  
    365386            action_flag     = DELETION
    366387        )
    367388   
     389    def action_checkbox(self, obj):
     390        """
     391        A list_display column containing a checkbox widget.
     392        """
     393        return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, force_unicode(obj.pk))
     394    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle" />')
     395    action_checkbox.allow_tags = True
     396   
     397    def get_action_choices(self, default_choices=BLANK_CHOICE_DASH):
     398        choices = [] + default_choices
     399        for action in getattr(self, 'actions', []):
     400            func, name, description, instance_action = self.get_action(action)
     401            choice = (name, description % model_format_dict(self.opts))
     402            choices.append(choice)
     403        return choices
     404
     405    def get_action(self, action):
     406        is_instance_action = False
     407        if callable(action):
     408            func = action
     409            action = action.__name__
     410        elif hasattr(self, action):
     411            func = getattr(self, action)
     412        elif hasattr(self.model, action):
     413            func = getattr(self.model, action)
     414            is_instance_action = True
     415        else:
     416            if action in [name for name in self.callable_actions]:
     417                return self.get_action(self.callable_actions[action])
     418            raise AttributeError, \
     419                "'%s' model or '%s' have no action '%s'" % \
     420                    (self.opts.object_name, self.__class__.__name__, action)
     421        if hasattr(func, 'short_description'):
     422            description = func.short_description
     423        else:
     424            description = capfirst(action.replace('_', ' '))
     425        return func, action, description, is_instance_action
     426   
     427    def delete_selected(self, request, selected):
     428        """
     429        Default action which deletes the selected objects.
     430        """
     431        if self.has_delete_permission(request):
     432            n = selected.count()
     433            if n:
     434                for obj in selected:
     435                    obj_display = force_unicode(obj)
     436                    self.log_deletion(request, obj, obj_display)
     437                selected.delete()
     438                self.message_user(request, _("Successfully deleted %d %s.") % (
     439                    n, model_ngettext(self.opts, n)
     440                ))
     441    delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s")
    368442   
    369443    def construct_change_message(self, request, form, formsets):
    370444        """
    class ModelAdmin(BaseModelAdmin):  
    503577        else:
    504578            self.message_user(request, msg)
    505579            return HttpResponseRedirect("../")
     580
     581    def response_action(self, request, changelist):
     582        if request.method == 'POST':
     583            # There can be multiple action forms on the page (at the top
     584            # and bottom of the change list, for example). Get the action
     585            # whose button was pushed.
     586            try:
     587                action_index = int(request.POST.get('index', 0))
     588            except ValueError:
     589                action_index = 0
     590            data = {}
     591            for key in request.POST:
     592                if key not in (helpers.ACTION_CHECKBOX_NAME, 'index'):
     593                    data[key] = request.POST.getlist(key)[action_index]
     594            action_form = self.action_form(data, auto_id=None)
     595            action_form.fields['action'].choices = self.get_action_choices()
     596           
     597            if action_form.is_valid():
     598                action = action_form.cleaned_data['action']
     599                func, name, description, instance_action = self.get_action(action)
     600                selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME)
     601                results = changelist.get_query_set().filter(pk__in=selected)
     602                response = None
     603                if callable(func):
     604                    if instance_action:
     605                        for obj in results:
     606                            getattr(obj, name)(request)
     607                    else:
     608                        response = func(request, results)
     609                if isinstance(response, HttpResponse):
     610                    return response
     611                else:
     612                    redirect_to = request.META.get('HTTP_REFERER') or "."
     613                    return HttpResponseRedirect(redirect_to)
     614        else:
     615            action_form = self.action_form(auto_id=None)
     616            action_form.fields['action'].choices = self.get_action_choices()
     617        return action_form
    506618   
    507619    def add_view(self, request, form_url='', extra_context=None):
    508620        "The 'add' admin view for this model."
    class ModelAdmin(BaseModelAdmin):  
    696808                return render_to_response('admin/invalid_setup.html', {'title': _('Database error')})
    697809            return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1')
    698810       
     811        action_response = self.response_action(request, cl)
     812        if isinstance(action_response, HttpResponse):
     813            return action_response
     814       
    699815        context = {
    700816            'title': cl.title,
    701817            'is_popup': cl.is_popup,
    702818            'cl': cl,
     819            'media': mark_safe(self.media),
    703820            'has_add_permission': self.has_add_permission(request),
    704821            'root_path': self.admin_site.root_path,
    705822            'app_label': app_label,
     823            'action_form': action_response,
     824            'actions_on_top': self.actions_on_top,
     825            'actions_on_bottom': self.actions_on_bottom,
    706826        }
    707827        context.update(extra_context or {})
    708828        return render_to_response(self.change_list_template or [
  • new file django/contrib/admin/templates/admin/actions.html

    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
    - +  
     1{% load i18n %}
     2<div class="actions">
     3    {% for field in action_form %}<label>{{ field.label }} {{ field }}</label>{% endfor %}
     4    <button type="submit" class="button" title="{% trans "Run the selected action" %}" name="index" value="{{ action_index|default:0 }}">{% trans "Go" %}</button>
     5</div>
  • django/contrib/admin/templates/admin/change_list.html

    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 b  
    33
    44{% block extrastyle %}{{ block.super }}<link rel="stylesheet" type="text/css" href="{% admin_media_prefix %}css/changelists.css" />{% endblock %}
    55
     6{% block extrahead %}{{ block.super }}{{ media }}{% endblock %}
     7
    68{% block bodyclass %}change-list{% endblock %}
    79
    810{% 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 %}
     
    3133{% endif %}
    3234{% endblock %}
    3335
    34 {% block result_list %}{% result_list cl %}{% endblock %}
     36{% block result_list %}
     37<form id="action-form" method="post" action="">
     38    {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %}
     39    {% result_list cl %}
     40    {% if actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %}
     41</form>
     42{% endblock %}
    3543{% block pagination %}{% pagination cl %}{% endblock %}
    3644</div>
    3745</div>
  • django/contrib/admin/templatetags/admin_list.py

    diff --git a/django/contrib/admin/templatetags/admin_list.py b/django/contrib/admin/templatetags/admin_list.py
    index 37cdb91..59fc4ff 100644
    a b search_form = register.inclusion_tag('admin/search_form.html')(search_form)  
    311311def admin_list_filter(cl, spec):
    312312    return {'title': spec.title(), 'choices' : list(spec.choices(cl))}
    313313admin_list_filter = register.inclusion_tag('admin/filter.html')(admin_list_filter)
     314
     315def admin_actions(context):
     316    """
     317    Track the number of times the action field has been rendered on the page,
     318    so we know which value to use.
     319    """
     320    context['action_index'] = context.get('action_index', -1) + 1
     321    return context
     322admin_actions = register.inclusion_tag("admin/actions.html", takes_context=True)(admin_actions)
  • django/contrib/admin/util.py

    diff --git a/django/contrib/admin/util.py b/django/contrib/admin/util.py
    index 4164c8a..7c2b56b 100644
    a b from django.utils.html import escape  
    44from django.utils.safestring import mark_safe
    55from django.utils.text import capfirst
    66from django.utils.encoding import force_unicode
    7 from django.utils.translation import ugettext as _
     7from django.utils.translation import ungettext, ugettext as _
    88
    99def quote(s):
    1010    """
    def get_deleted_objects(deleted_objects, perms_needed, user, obj, opts, current_  
    155155            p = u'%s.%s' % (related.opts.app_label, related.opts.get_change_permission())
    156156            if not user.has_perm(p):
    157157                perms_needed.add(related.opts.verbose_name)
     158
     159def model_format_dict(obj):
     160    """
     161    Return a `dict` with keys 'verbose_name' and 'verbose_name_plural',
     162    typically for use with string formatting.
     163
     164    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
     165
     166    """
     167    if isinstance(obj, (models.Model, models.base.ModelBase)):
     168        opts = obj._meta
     169    elif isinstance(obj, models.query.QuerySet):
     170        opts = obj.model._meta
     171    else:
     172        opts = obj
     173    return {
     174        'verbose_name': force_unicode(opts.verbose_name),
     175        'verbose_name_plural': force_unicode(opts.verbose_name_plural)
     176    }
     177
     178def model_ngettext(obj, n=None):
     179    """
     180    Return the appropriate `verbose_name` or `verbose_name_plural` for `obj`
     181    depending on the count `n`.
     182
     183    `obj` may be a `Model` instance, `Model` subclass, or `QuerySet` instance.
     184    If `obj` is a `QuerySet` instance, `n` is optional and the length of the
     185    `QuerySet` is used.
     186
     187    """
     188    if isinstance(obj, models.query.QuerySet):
     189        if n is None:
     190            n = obj.count()
     191        obj = obj.model
     192    d = model_format_dict(obj)
     193    return ungettext(d['verbose_name'], d['verbose_name_plural'], n or 0)
  • new file tests/regressiontests/admin_views/fixtures/admin-views-actions.xml

    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
    - +  
     1<?xml version="1.0" encoding="utf-8"?>
     2<django-objects version="1.0">
     3    <object pk="1" model="admin_views.subscriber">
     4        <field type="CharField" name="name">John Doe</field>
     5        <field type="CharField" name="email">john@example.org</field>
     6    </object>
     7    <object pk="2" model="admin_views.subscriber">
     8        <field type="CharField" name="name">Max Mustermann</field>
     9        <field type="CharField" name="email">max@example.org</field>
     10    </object>
     11    <object pk="1" model="admin_views.directsubscriber">
     12        <field type="CharField" name="name">John Doe</field>
     13        <field type="CharField" name="email">john@example.org</field>
     14        <field type="BooleanField" name="paid">True</field>
     15    </object>
     16    <object pk="1" model="admin_views.externalsubscriber">
     17        <field type="CharField" name="name">John Doe</field>
     18        <field type="CharField" name="email">john@example.org</field>
     19    </object>
     20</django-objects>
     21 No newline at end of file
  • tests/regressiontests/admin_views/models.py

    diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py
    index d849a7b..fa4faa3 100644
    a b  
    11# -*- coding: utf-8 -*-
    22from django.db import models
    33from django.contrib import admin
     4from django.core.mail import EmailMessage
    45
    56class Section(models.Model):
    67    """
    class PersonaAdmin(admin.ModelAdmin):  
    177178        BarAccountAdmin
    178179    )
    179180
     181class Subscriber(models.Model):
     182    name = models.CharField(blank=False, max_length=80)
     183    email = models.EmailField(blank=False, max_length=175)
     184
     185    def __unicode__(self):
     186        return "%s (%s)" % (self.name, self.email)
     187
     188class SubscriberAdmin(admin.ModelAdmin):
     189    actions = ['delete_selected', 'mail_admin']
     190
     191    def mail_admin(self, request, selected):
     192        EmailMessage(
     193            'Greetings from a ModelAdmin action',
     194            'This is the test email from a admin action',
     195            'from@example.com',
     196            ['to@example.com']
     197        ).send()
     198
     199class DirectSubscriber(Subscriber):
     200    paid = models.BooleanField(default=False)
     201
     202    def direct_mail(self, request):
     203        EmailMessage(
     204            'Greetings from a model action',
     205            'This is the test email from a model action',
     206            'from@example.com',
     207            [self.email]
     208        ).send()
     209
     210class DirectSubscriberAdmin(admin.ModelAdmin):
     211    actions = ['direct_mail']
     212
     213class ExternalSubscriber(Subscriber):
     214    pass
     215
     216def external_mail(request, selected):
     217    EmailMessage(
     218        'Greetings from a function action',
     219        'This is the test email from a function action',
     220        'from@example.com',
     221        ['to@example.com']
     222    ).send()
     223
     224def redirect_to(request, selected):
     225    from django.http import HttpResponseRedirect
     226    return HttpResponseRedirect('/some-where-else/')
     227
     228class ExternalSubscriberAdmin(admin.ModelAdmin):
     229    actions = [external_mail, redirect_to]
     230
    180231admin.site.register(Article, ArticleAdmin)
    181232admin.site.register(CustomArticle, CustomArticleAdmin)
    182233admin.site.register(Section, inlines=[ArticleInline])
    admin.site.register(ModelWithStringPrimaryKey)  
    184235admin.site.register(Color)
    185236admin.site.register(Thing, ThingAdmin)
    186237admin.site.register(Persona, PersonaAdmin)
     238admin.site.register(Subscriber, SubscriberAdmin)
     239admin.site.register(DirectSubscriber, DirectSubscriberAdmin)
     240admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin)
    187241
    188242# We intentionally register Promo and ChapterXtra1 but not Chapter nor ChapterXtra2.
    189243# That way we cover all four cases:
  • tests/regressiontests/admin_views/tests.py

    diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py
    index bf198bc..612e1ec 100644
    a b from django.contrib.contenttypes.models import ContentType  
    88from django.contrib.admin.models import LogEntry
    99from django.contrib.admin.sites import LOGIN_FORM_KEY
    1010from django.contrib.admin.util import quote
     11from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
    1112from django.utils.html import escape
    1213
    1314# local test models
    14 from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Persona, FooAccount, BarAccount
     15from models import Article, CustomArticle, Section, ModelWithStringPrimaryKey, Persona, FooAccount, BarAccount, Subscriber, DirectSubscriber, ExternalSubscriber
    1516
    1617try:
    1718    set
    class AdminInheritedInlinesTest(TestCase):  
    805806        self.failUnlessEqual(FooAccount.objects.all()[0].username, "%s-1" % foo_user)
    806807        self.failUnlessEqual(BarAccount.objects.all()[0].username, "%s-1" % bar_user)
    807808        self.failUnlessEqual(Persona.objects.all()[0].accounts.count(), 2)
     809
     810from django.core import mail
     811
     812class AdminActionsTest(TestCase):
     813    fixtures = ['admin-views-users.xml', 'admin-views-actions.xml']
     814
     815    def setUp(self):
     816        self.client.login(username='super', password='secret')
     817
     818    def tearDown(self):
     819        self.client.logout()
     820
     821    def test_model_admin_custom_action(self):
     822        "Tests a custom action defined in a ModelAdmin method"
     823        action_data = {
     824            ACTION_CHECKBOX_NAME: [1],
     825            'action' : 'mail_admin',
     826            'index': 0,
     827        }
     828        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
     829        self.assertEquals(len(mail.outbox), 1)
     830        self.assertEquals(mail.outbox[0].subject, 'Greetings from a ModelAdmin action')
     831
     832    def test_model_admin_default_delete_action(self):
     833        "Tests the default delete action defined as a ModelAdmin method"
     834        action_data = {
     835            ACTION_CHECKBOX_NAME: [1, 2],
     836            'action' : 'delete_selected',
     837            'index': 0,
     838        }
     839        response = self.client.post('/test_admin/admin/admin_views/subscriber/', action_data)
     840        self.failUnlessEqual(Subscriber.objects.count(), 0)
     841
     842    def test_custom_model_instance_action(self):
     843        "Tests a custom action defined in a model method"
     844        action_data = {
     845            ACTION_CHECKBOX_NAME: [1],
     846            'action' : 'direct_mail',
     847            'index': 0,
     848            'paid': 1,
     849        }
     850        response = self.client.post('/test_admin/admin/admin_views/directsubscriber/', action_data)
     851        self.assertEquals(len(mail.outbox), 1)
     852        self.assertEquals(mail.outbox[0].subject, 'Greetings from a model action')
     853        self.assertEquals(mail.outbox[0].to, [u'john@example.org'])
     854
     855    def test_custom_function_mail_action(self):
     856        "Tests a custom action defined in a function"
     857        action_data = {
     858            ACTION_CHECKBOX_NAME: [1],
     859            'action' : 'external_mail',
     860            'index': 0,
     861        }
     862        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
     863        self.assertEquals(len(mail.outbox), 1)
     864        self.assertEquals(mail.outbox[0].subject, 'Greetings from a function action')
     865
     866    def test_custom_function_action_with_redirect(self):
     867        "Tests a custom action defined in a function"
     868        action_data = {
     869            ACTION_CHECKBOX_NAME: [1],
     870            'action' : 'redirect_to',
     871            'index': 0,
     872        }
     873        response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data)
     874        self.failUnlessEqual(response.status_code, 302)
Back to Top