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('') + 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 %} +