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..649cff7 100644 --- a/django/contrib/admin/media/css/changelists.css +++ b/django/contrib/admin/media/css/changelists.css @@ -50,12 +50,24 @@ #changelist table thead th { white-space: nowrap; + vertical-align: middle; +} + +#changelist table thead th:first-child { + width: 1.5em; + text-align: center; } #changelist table tbody td { border-left: 1px solid #ddd; } +#changelist table tbody td:first-child { + border-left: 0; + border-right: 1px solid #ddd; + text-align: center; +} + #changelist table tfoot { color: #666; } @@ -209,3 +221,35 @@ border-color: #036; } +/* ACTIONS */ + +.filtered .actions { + margin-right: 160px !important; + border-right: 1px solid #ddd; +} + +#changelist .actions { + color: #666; + padding: 3px; + border-bottom: 1px solid #ddd; + background: #e1e1e1 url(../img/admin/nav-bg.gif) top left repeat-x; +} + +#changelist .actions:last-child { + border-bottom: none; +} + +#changelist .actions select { + border: 1px solid #aaa; + margin: 0 0.5em; + padding: 1px 2px; +} + +#changelist .actions label { + font-size: 11px; + margin: 0 0.5em; +} + +#changelist #action-toggle { + display: none; +} 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 69f52aa..096960d 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 @@ -16,7 +17,7 @@ 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 ngettext +from django.utils.translation import ngettext, ugettext_lazy from django.utils.encoding import force_unicode try: set @@ -173,7 +174,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 @@ -192,6 +193,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 @@ -200,6 +207,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): @@ -239,6 +254,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(): @@ -390,6 +407,96 @@ 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_actions(self, request=None): + actions = {} + for klass in self.__class__.mro(): + for action in getattr(klass, 'actions', []): + func, name, description = self.get_action(action) + actions[name] = (func, name, description) + return actions + + def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH): + choices = [] + default_choices + for func, name, description in self.get_actions(request).itervalues(): + choice = (name, description % model_format_dict(self.opts)) + choices.append(choice) + return choices + + def get_action(self, action): + if callable(action): + func = action + action = action.__name__ + elif hasattr(self, action): + func = getattr(self, action) + if hasattr(func, 'short_description'): + description = func.short_description + else: + description = capfirst(action.replace('_', ' ')) + return func, action, description + + def delete_selected(self, request, selected): + """ + Default action which deletes the selected objects. + """ + opts = self.model._meta + app_label = opts.app_label + + if not self.has_delete_permission(request): + raise PermissionDenied + + # Populate deleted_objects, a data structure of all related objects that + # will also be deleted. + + # deleted_objects must be a list if we want to use '|unordered_list' in the template + deleted_objects = [] + perms_needed = set() + i = 0 + for obj in selected: + deleted_objects.append([mark_safe(u'%s: %s' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []]) + get_deleted_objects(deleted_objects[i], perms_needed, request.user, obj, opts, 1, self.admin_site, levels_to_root=2) + i=i+1 + + # The user has already confirmed the deletion. + if request.POST.get('post'): + if perms_needed: + raise PermissionDenied + 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) + )) + return None + + context = { + "title": _("Are you sure?"), + "object_name": force_unicode(opts.verbose_name), + "deleted_objects": deleted_objects, + 'selected': selected, + "perms_lacking": perms_needed, + "opts": opts, + "root_path": self.admin_site.root_path, + "app_label": app_label, + } + return render_to_response(self.delete_confirmation_template or [ + "admin/%s/%s/delete_selected_confirmation.html" % (app_label, opts.object_name.lower()), + "admin/%s/delete_selected_confirmation.html" % app_label, + "admin/delete_selected_confirmation.html" + ], context, context_instance=template.RequestContext(request)) + + delete_selected.short_description = ugettext_lazy("Delete selected %(verbose_name_plural)s") def construct_change_message(self, request, form, formsets): """ @@ -529,6 +636,40 @@ class ModelAdmin(BaseModelAdmin): self.message_user(request, msg) return HttpResponseRedirect("../") + def response_action(self, request, queryset): + 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(request) + + if action_form.is_valid(): + action = action_form.cleaned_data['action'] + func, name, description = self.get_actions(request)[action] + selected = request.POST.getlist(helpers.ACTION_CHECKBOX_NAME) + results = queryset.filter(pk__in=selected) + response = None + if callable(func): + 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(request) + return action_form + def add_view(self, request, form_url='', extra_context=None): "The 'add' admin view for this model." model = self.model @@ -721,6 +862,10 @@ class ModelAdmin(BaseModelAdmin): return render_to_response('admin/invalid_setup.html', {'title': _('Database error')}) return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1') + action_form_or_response = self.response_action(request, queryset=cl.get_query_set()) + if isinstance(action_form_or_response, HttpResponse): + return action_form_or_response + # If we're allowing changelist editing, we need to construct a formset # for the changelist given all the fields to be edited. Then we'll # use the formset to validate/process POSTed data. @@ -764,7 +909,7 @@ class ModelAdmin(BaseModelAdmin): if formset: media = self.media + formset.media else: - media = None + media = self.media context = { 'title': cl.title, @@ -774,6 +919,9 @@ class ModelAdmin(BaseModelAdmin): 'has_add_permission': self.has_add_permission(request), 'root_path': self.admin_site.root_path, 'app_label': app_label, + 'action_form': action_form_or_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/sites.py b/django/contrib/admin/sites.py index 5171e71..ebcf886 100644 --- a/django/contrib/admin/sites.py +++ b/django/contrib/admin/sites.py @@ -44,6 +44,8 @@ class AdminSite(object): else: name += '_' self.name = name + + self.actions = [] def register(self, model_or_iterable, admin_class=None, **options): """ @@ -81,6 +83,9 @@ class AdminSite(object): options['__module__'] = __name__ admin_class = type("%sAdmin" % model.__name__, (admin_class,), options) + for action in self.actions: + admin_class.actions.append(action) + # Validate (which might be a no-op) validate(admin_class, model) @@ -100,6 +105,13 @@ class AdminSite(object): raise NotRegistered('The model %s is not registered' % model.__name__) del self._registry[model] + def add_action(self, action): + if not callable(action): + raise TypeError("You can only register callable actions through an admin site") + self.actions.append(action) + for klass in self._registery.itervalues(): + klass.actions.append(action) + def has_permission(self, request): """ Returns True if the given HttpRequest has permission to view 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 %} +
{% 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 %}
+{% 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 %}
+ {% for d in deleted_objects %} +