From bb15cee58a43eeb0d060f8a31f9078b3406f195a Mon Sep 17 00:00:00 2001 From: Jacob Kaplan-Moss Date: Mon, 6 Apr 2009 20:23:33 +0000 Subject: [PATCH] Made a bunch of improvements to admin actions. Be warned: this includes one minor but BACKWARDS-INCOMPATIBLE change. These changes are: * BACKWARDS-INCOMPATIBLE CHANGE: action functions and action methods now share the same signature: `(modeladmin, request, queryset)`. Actions defined as methods stay the same, but if you've defined an action as a standalone function you'll now need to add that first `modeladmin` argument. * The delete selected action is now a standalone function registered site-wide; this makes disabling it easy. * Fixed #10596: there are now official, documented `AdminSite` APIs for dealing with actions, including a method to disable global actions. You can still re-enable globally-disabled actions on a case-by-case basis. * Fixed #10595: you can now disable actions for a particular `ModelAdmin` by setting `actions` to `None`. * Fixed #10734: actions are now sorted (by name). * Fixed #10618: the action is now taken from the form whose "submit" button you clicked, not arbitrarily the last form on the page. * All of the above is documented and tested. git-svn-id: http://code.djangoproject.com/svn/django/trunk@10408 bcc190cf-cafb-0310-a4f2-bffc1f526a37 --- django/contrib/admin/actions.py | 81 +++++++++ django/contrib/admin/options.py | 170 +++++++++--------- django/contrib/admin/sites.py | 40 ++++- .../admin/templates/admin/change_list.html | 9 +- .../admin/templates/admin/pagination.html | 2 +- django/contrib/admin/validation.py | 8 - docs/ref/contrib/admin/actions.txt | 149 ++++++++++++--- tests/regressiontests/admin_views/models.py | 13 +- tests/regressiontests/admin_views/tests.py | 26 +++ 9 files changed, 367 insertions(+), 131 deletions(-) create mode 100644 django/contrib/admin/actions.py diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py new file mode 100644 index 0000000000..d35319435d --- /dev/null +++ b/django/contrib/admin/actions.py @@ -0,0 +1,81 @@ +""" +Built-in, globally-available admin actions. +""" + +from django import template +from django.core.exceptions import PermissionDenied +from django.contrib.admin import helpers +from django.contrib.admin.util import get_deleted_objects, model_ngettext +from django.shortcuts import render_to_response +from django.utils.encoding import force_unicode +from django.utils.html import escape +from django.utils.safestring import mark_safe +from django.utils.text import capfirst +from django.utils.translation import ugettext_lazy, ugettext as _ + +def delete_selected(modeladmin, request, queryset): + """ + Default action which deletes the selected objects. + + This action first displays a confirmation page whichs shows all the + deleteable objects, or, if the user has no permission one of the related + childs (foreignkeys), a "permission denied" message. + + Next, it delets all selected objects and redirects back to the change list. + """ + opts = modeladmin.model._meta + app_label = opts.app_label + + # Check that the user has delete permission for the actual model + if not modeladmin.has_delete_permission(request): + raise PermissionDenied + + # Populate deletable_objects, a data structure of all related objects that + # will also be deleted. + + # deletable_objects must be a list if we want to use '|unordered_list' in the template + deletable_objects = [] + perms_needed = set() + i = 0 + for obj in queryset: + deletable_objects.append([mark_safe(u'%s: %s' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []]) + get_deleted_objects(deletable_objects[i], perms_needed, request.user, obj, opts, 1, modeladmin.admin_site, levels_to_root=2) + i=i+1 + + # The user has already confirmed the deletion. + # Do the deletion and return a None to display the change list view again. + if request.POST.get('post'): + if perms_needed: + raise PermissionDenied + n = queryset.count() + if n: + for obj in queryset: + obj_display = force_unicode(obj) + modeladmin.log_deletion(request, obj, obj_display) + queryset.delete() + modeladmin.message_user(request, _("Successfully deleted %(count)d %(items)s.") % { + "count": n, "items": model_ngettext(modeladmin.opts, n) + }) + # Return None to display the change list page again. + return None + + context = { + "title": _("Are you sure?"), + "object_name": force_unicode(opts.verbose_name), + "deletable_objects": deletable_objects, + 'queryset': queryset, + "perms_lacking": perms_needed, + "opts": opts, + "root_path": modeladmin.admin_site.root_path, + "app_label": app_label, + 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, + } + + # Display the confirmation page + return render_to_response(modeladmin.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") diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 7cb1aac959..8035caf1db 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -11,6 +11,7 @@ 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.datastructures import SortedDict from django.utils.functional import update_wrapper from django.utils.html import escape from django.utils.safestring import mark_safe @@ -194,7 +195,7 @@ class ModelAdmin(BaseModelAdmin): object_history_template = None # Actions - actions = ['delete_selected'] + actions = [] action_form = helpers.ActionForm actions_on_top = True actions_on_bottom = False @@ -207,7 +208,7 @@ 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: + if 'action_checkbox' not in self.list_display and self.actions is not None: self.list_display = ['action_checkbox'] + list(self.list_display) if not self.list_display_links: for name in self.list_display: @@ -253,7 +254,7 @@ class ModelAdmin(BaseModelAdmin): from django.conf import settings js = ['js/core.js', 'js/admin/RelatedObjectLookups.js'] - if self.actions: + if self.actions is not None: js.extend(['js/getElementsBySelector.js', 'js/actions.js']) if self.prepopulated_fields: js.append('js/urlify.js') @@ -414,19 +415,46 @@ class ModelAdmin(BaseModelAdmin): action_checkbox.short_description = mark_safe('') action_checkbox.allow_tags = True - def get_actions(self, request=None): + def get_actions(self, request): """ Return a dictionary mapping the names of all actions for this ModelAdmin to a tuple of (callable, name, description) for each action. """ - actions = {} - for klass in [self.admin_site] + self.__class__.mro()[::-1]: - for action in getattr(klass, 'actions', []): - func, name, description = self.get_action(action) - actions[name] = (func, name, description) + # If self.actions is explicitally set to None that means that we don't + # want *any* actions enabled on this page. + if self.actions is None: + return [] + + actions = [] + + # Gather actions from the admin site first + for (name, func) in self.admin_site.actions: + description = getattr(func, 'short_description', name.replace('_', ' ')) + actions.append((func, name, description)) + + # Then gather them from the model admin and all parent classes, + # starting with self and working back up. + for klass in self.__class__.mro()[::-1]: + class_actions = getattr(klass, 'actions', []) + # Avoid trying to iterate over None + if not class_actions: + continue + actions.extend([self.get_action(action) for action in class_actions]) + + # get_action might have returned None, so filter any of those out. + actions = filter(None, actions) + + # Convert the actions into a SortedDict keyed by name + # and sorted by description. + actions.sort(lambda a,b: cmp(a[2].lower(), b[2].lower())) + actions = SortedDict([ + (name, (func, name, desc)) + for func, name, desc in actions + ]) + return actions - def get_action_choices(self, request=None, default_choices=BLANK_CHOICE_DASH): + def get_action_choices(self, request, default_choices=BLANK_CHOICE_DASH): """ Return a list of choices for use in a form object. Each choice is a tuple (name, description). @@ -443,85 +471,30 @@ class ModelAdmin(BaseModelAdmin): or the name of a method on the ModelAdmin. Return is a tuple of (callable, name, description). """ + # If the action is a callable, just use it. if callable(action): func = action action = action.__name__ - elif hasattr(self, action): - func = getattr(self, action) + + # Next, look for a method. Grab it off self.__class__ to get an unbound + # method instead of a bound one; this ensures that the calling + # conventions are the same for functions and methods. + elif hasattr(self.__class__, action): + func = getattr(self.__class__, action) + + # Finally, look for a named method on the admin site + else: + try: + func = self.admin_site.get_action(action) + except KeyError: + return None + if hasattr(func, 'short_description'): description = func.short_description else: description = capfirst(action.replace('_', ' ')) return func, action, description - def delete_selected(self, request, queryset): - """ - Default action which deletes the selected objects. - - In the first step, it displays a confirmation page whichs shows all - the deleteable objects or, if the user has no permission one of the - related childs (foreignkeys) it displays a "permission denied" message. - - In the second step delete all selected objects and display the change - list again. - """ - opts = self.model._meta - app_label = opts.app_label - - # Check that the user has delete permission for the actual model - if not self.has_delete_permission(request): - raise PermissionDenied - - # Populate deletable_objects, a data structure of all related objects that - # will also be deleted. - - # deletable_objects must be a list if we want to use '|unordered_list' in the template - deletable_objects = [] - perms_needed = set() - i = 0 - for obj in queryset: - deletable_objects.append([mark_safe(u'%s: %s' % (escape(force_unicode(capfirst(opts.verbose_name))), obj.pk, escape(obj))), []]) - get_deleted_objects(deletable_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. - # Do the deletion and return a None to display the change list view again. - if request.POST.get('post'): - if perms_needed: - raise PermissionDenied - n = queryset.count() - if n: - for obj in queryset: - obj_display = force_unicode(obj) - self.log_deletion(request, obj, obj_display) - queryset.delete() - self.message_user(request, _("Successfully deleted %(count)d %(items)s.") % { - "count": n, "items": model_ngettext(self.opts, n) - }) - # Return None to display the change list page again. - return None - - context = { - "title": _("Are you sure?"), - "object_name": force_unicode(opts.verbose_name), - "deletable_objects": deletable_objects, - 'queryset': queryset, - "perms_lacking": perms_needed, - "opts": opts, - "root_path": self.admin_site.root_path, - "app_label": app_label, - 'action_checkbox_name': helpers.ACTION_CHECKBOX_NAME, - } - - # Display the confirmation page - 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): """ Construct a change message from a changed object. @@ -678,6 +651,16 @@ class ModelAdmin(BaseModelAdmin): data = request.POST.copy() data.pop(helpers.ACTION_CHECKBOX_NAME, None) data.pop("index", None) + + # Use the action whose button was pushed + try: + data.update({'action': data.getlist('action')[action_index]}) + except IndexError: + # If we didn't get an action from the chosen form that's invalid + # POST data, so by deleting action it'll fail the validation check + # below. So no need to do anything here + pass + action_form = self.action_form(data, auto_id=None) action_form.fields['action'].choices = self.get_action_choices(request) @@ -692,7 +675,7 @@ class ModelAdmin(BaseModelAdmin): if not selected: return None - response = func(request, queryset.filter(pk__in=selected)) + response = func(self, request, queryset.filter(pk__in=selected)) # Actions may return an HttpResponse, which will be used as the # response from the POST. If not, we'll be a good little HTTP @@ -881,8 +864,20 @@ class ModelAdmin(BaseModelAdmin): app_label = opts.app_label if not self.has_change_permission(request, None): raise PermissionDenied + + # Check actions to see if any are available on this changelist + actions = self.get_actions(request) + + # Remove action checkboxes if there aren't any actions available. + list_display = list(self.list_display) + if not actions: + try: + list_display.remove('action_checkbox') + except ValueError: + pass + try: - cl = ChangeList(request, self.model, self.list_display, self.list_display_links, self.list_filter, + cl = ChangeList(request, self.model, list_display, self.list_display_links, self.list_filter, self.date_hierarchy, self.search_fields, self.list_select_related, self.list_per_page, self.list_editable, self) except IncorrectLookupParameters: # Wacky lookup parameters were given, so redirect to the main @@ -893,11 +888,11 @@ class ModelAdmin(BaseModelAdmin): if ERROR_FLAG in request.GET.keys(): return render_to_response('admin/invalid_setup.html', {'title': _('Database error')}) return HttpResponseRedirect(request.path + '?' + ERROR_FLAG + '=1') - + # If the request was POSTed, this might be a bulk action or a bulk edit. # Try to look up an action first, but if this isn't an action the POST # will fall through to the bulk edit check, below. - if request.method == 'POST': + if actions and request.method == 'POST': response = self.response_action(request, queryset=cl.get_query_set()) if response: return response @@ -948,8 +943,11 @@ class ModelAdmin(BaseModelAdmin): media = self.media # Build the action form and populate it with available actions. - action_form = self.action_form(auto_id=None) - action_form.fields['action'].choices = self.get_action_choices(request) + if actions: + action_form = self.action_form(auto_id=None) + action_form.fields['action'].choices = self.get_action_choices(request) + else: + action_form = None context = { 'title': cl.title, diff --git a/django/contrib/admin/sites.py b/django/contrib/admin/sites.py index 8b579853d4..4e1fb37021 100644 --- a/django/contrib/admin/sites.py +++ b/django/contrib/admin/sites.py @@ -1,6 +1,7 @@ import re from django import http, template from django.contrib.admin import ModelAdmin +from django.contrib.admin import actions from django.contrib.auth import authenticate, login from django.db.models.base import ModelBase from django.core.exceptions import ImproperlyConfigured @@ -11,6 +12,10 @@ from django.utils.text import capfirst from django.utils.translation import ugettext_lazy, ugettext as _ from django.views.decorators.cache import never_cache from django.conf import settings +try: + set +except NameError: + from sets import Set as set # Python 2.3 fallback ERROR_MESSAGE = ugettext_lazy("Please enter a correct username and password. Note that both fields are case-sensitive.") LOGIN_FORM_KEY = 'this_is_the_login_form' @@ -44,8 +49,8 @@ class AdminSite(object): else: name += '_' self.name = name - - self.actions = [] + self._actions = {'delete_selected': actions.delete_selected} + self._global_actions = self._actions.copy() def register(self, model_or_iterable, admin_class=None, **options): """ @@ -102,10 +107,33 @@ 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) + def add_action(self, action, name=None): + """ + Register an action to be available globally. + """ + name = name or action.__name__ + self._actions[name] = action + self._global_actions[name] = action + + def disable_action(self, name): + """ + Disable a globally-registered action. Raises KeyError for invalid names. + """ + del self._actions[name] + + def get_action(self, name): + """ + Explicitally get a registered global action wheather it's enabled or + not. Raises KeyError for invalid names. + """ + return self._global_actions[name] + + def actions(self): + """ + Get all the enabled actions as an iterable of (name, func). + """ + return self._actions.iteritems() + actions = property(actions) def has_permission(self, request): """ diff --git a/django/contrib/admin/templates/admin/change_list.html b/django/contrib/admin/templates/admin/change_list.html index 63254b868e..31bf7bd29a 100644 --- a/django/contrib/admin/templates/admin/change_list.html +++ b/django/contrib/admin/templates/admin/change_list.html @@ -9,6 +9,11 @@ {% endif %} {{ media }} + {% if not actions_on_top and not actions_on_bottom %} + + {% endif %} {% endblock %} {% block bodyclass %}change-list{% endblock %} @@ -69,9 +74,9 @@ {% endif %} {% block result_list %} - {% if actions_on_top and cl.full_result_count %}{% admin_actions %}{% endif %} + {% if action_form and 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 %} + {% if action_form and actions_on_bottom and cl.full_result_count %}{% admin_actions %}{% endif %} {% endblock %} {% block pagination %}{% pagination cl %}{% endblock %} diff --git a/django/contrib/admin/templates/admin/pagination.html b/django/contrib/admin/templates/admin/pagination.html index 58ade6ad0c..aaba97fdb7 100644 --- a/django/contrib/admin/templates/admin/pagination.html +++ b/django/contrib/admin/templates/admin/pagination.html @@ -8,5 +8,5 @@ {% endif %} {{ cl.result_count }} {% ifequal cl.result_count 1 %}{{ cl.opts.verbose_name }}{% else %}{{ cl.opts.verbose_name_plural }}{% endifequal %} {% if show_all_url %}  {% trans 'Show all' %}{% endif %} -{% if cl.formset %}{% endif %} +{% if cl.formset and cl.result_count %}{% endif %}

diff --git a/django/contrib/admin/validation.py b/django/contrib/admin/validation.py index 4fdbd0d6f4..3392714f5d 100644 --- a/django/contrib/admin/validation.py +++ b/django/contrib/admin/validation.py @@ -127,14 +127,6 @@ def validate(cls, model): continue get_field(cls, model, opts, 'ordering[%d]' % idx, field) - if cls.actions: - check_isseq(cls, 'actions', cls.actions) - for idx, item in enumerate(cls.actions): - if (not callable(item)) and (not hasattr(cls, item)): - raise ImproperlyConfigured("'%s.actions[%d]' is neither a " - "callable nor a method on %s" % (cls.__name__, idx, cls.__name__)) - - # list_select_related = False # save_as = False # save_on_top = False diff --git a/docs/ref/contrib/admin/actions.txt b/docs/ref/contrib/admin/actions.txt index 4969e97a99..2bc75c0b6e 100644 --- a/docs/ref/contrib/admin/actions.txt +++ b/docs/ref/contrib/admin/actions.txt @@ -31,8 +31,8 @@ Writing actions The easiest way to explain actions is by example, so let's dive in. -A common use case for admin actions is the bulk updating of a model. Imagine a simple -news application with an ``Article`` model:: +A common use case for admin actions is the bulk updating of a model. Imagine a +simple news application with an ``Article`` model:: from django.db import models @@ -61,12 +61,17 @@ Writing action functions First, we'll need to write a function that gets called when the action is trigged from the admin. Action functions are just regular functions that take -two arguments: an :class:`~django.http.HttpRequest` representing the current -request, and a :class:`~django.db.models.QuerySet` containing the set of -objects selected by the user. Our publish-these-articles function won't need -the request object, but we will use the queryset:: +three arguments: + + * The current :class:`ModelAdmin` + * An :class:`~django.http.HttpRequest` representing the current request, + * A :class:`~django.db.models.QuerySet` containing the set of objects + selected by the user. - def make_published(request, queryset): +Our publish-these-articles function won't need the :class:`ModelAdmin` or the +request object, but we will use the queryset:: + + def make_published(modeladmin, request, queryset): queryset.update(status='p') .. note:: @@ -86,7 +91,7 @@ the function name, with underscores replaced by spaces. That's fine, but we can provide a better, more human-friendly name by giving the ``make_published`` function a ``short_description`` attribute:: - def make_published(request, queryset): + def make_published(modeladmin, request, queryset): queryset.update(status='p') make_published.short_description = "Mark selected stories as published" @@ -106,7 +111,7 @@ the action and its registration would look like:: from django.contrib import admin from myapp.models import Article - def make_published(request, queryset): + def make_published(modeladmin, request, queryset): queryset.update(status='p') make_published.short_description = "Mark selected stories as published" @@ -150,14 +155,14 @@ That's easy enough to do:: queryset.update(status='p') make_published.short_description = "Mark selected stories as published" -Notice first that we've moved ``make_published`` into a method (remembering to -add the ``self`` argument!), and second that we've now put the string -``'make_published'`` in ``actions`` instead of a direct function reference. -This tells the :class:`ModelAdmin` to look up the action as a method. +Notice first that we've moved ``make_published`` into a method and renamed the +`modeladmin` parameter to `self`, and second that we've now put the string +``'make_published'`` in ``actions`` instead of a direct function reference. This +tells the :class:`ModelAdmin` to look up the action as a method. -Defining actions as methods is especially nice because it gives the action -access to the :class:`ModelAdmin` itself, allowing the action to call any of -the methods provided by the admin. +Defining actions as methods is gives the action more straightforward, idiomatic +access to the :class:`ModelAdmin` itself, allowing the action to call any of the +methods provided by the admin. For example, we can use ``self`` to flash a message to the user informing her that the action was successful:: @@ -208,8 +213,8 @@ you've written, passing the list of selected objects in the GET query string. This allows you to provide complex interaction logic on the intermediary pages. For example, if you wanted to provide a more complete export function, you'd want to let the user choose a format, and possibly a list of fields to -include in the export. The best thing to do would be to write a small action that simply redirects -to your custom export view:: +include in the export. The best thing to do would be to write a small action +that simply redirects to your custom export view:: from django.contrib import admin from django.contrib.contenttypes.models import ContentType @@ -226,14 +231,108 @@ hence the business with the ``ContentType``. Writing this view is left as an exercise to the reader. -Making actions available globally ---------------------------------- +.. _adminsite-actions: -Some actions are best if they're made available to *any* object in the admin --- the export action defined above would be a good candidate. You can make an -action globally available using :meth:`AdminSite.add_action()`:: +Making actions available site-wide +---------------------------------- - from django.contrib import admin +.. method:: AdminSite.add_action(action[, name]) + + Some actions are best if they're made available to *any* object in the admin + site -- the export action defined above would be a good candidate. You can + make an action globally available using :meth:`AdminSite.add_action()`. For + example:: + + from django.contrib import admin - admin.site.add_action(export_selected_objects) + admin.site.add_action(export_selected_objects) + + This makes the `export_selected_objects` action globally available as an + action named `"export_selected_objects"`. You can explicitly give the action + a name -- good if you later want to programatically :ref:`remove the action + ` -- by passing a second argument to + :meth:`AdminSite.add_action()`:: + admin.site.add_action(export_selected_objects, 'export_selected') + +.. _disabling-admin-actions: + +Disabling actions +----------------- + +Sometimes you need to disable certain actions -- especially those +:ref:`registered site-wide ` -- for particular objects. +There's a few ways you can disable actions: + +Disabling a site-wide action +~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. method:: AdminSite.disable_action(name) + + If you need to disable a :ref:`site-wide action ` you can + call :meth:`AdminSite.disable_action()`. + + For example, you can use this method to remove the built-in "delete selected + objects" action:: + + admin.site.disable_action('delete_selected') + + Once you've done the above, that action will no longer be available + site-wide. + + If, however, you need to re-enable a globally-disabled action for one + particular model, simply list it explicitally in your ``ModelAdmin.actions`` + list:: + + # Globally disable delete selected + admin.site.disable_action('delete_selected') + + # This ModelAdmin will not have delete_selected available + class SomeModelAdmin(admin.ModelAdmin): + actions = ['some_other_action'] + ... + + # This one will + class AnotherModelAdmin(admin.ModelAdmin): + actions = ['delete_selected', 'a_third_action'] + ... + + +Disabling all actions for a particular :class:`ModelAdmin` +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +If you want *no* bulk actions available for a given :class:`ModelAdmin`, simply +set :attr:`ModelAdmin.actions` to ``None``:: + + class MyModelAdmin(admin.ModelAdmin): + actions = None + +This tells the :class:`ModelAdmin` to not display or allow any actions, +including any :ref:`site-wide actions `. + +Conditionally enabling or disabling actions +~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ + +.. method:: ModelAdmin.get_actions(request) + + Finally, you can conditionally enable or disable actions on a per-request + (and hence per-user basis) by overriding :meth:`ModelAdmin.get_actions`. + + This returns a dictionary of actions allowed. The keys are action names, and + the values are ``(function, name, short_description)`` tuples. + + Most of the time you'll use this method to conditionally remove actions from + the list gathered by the superclass. For example, if I only wanted users + whose names begin with 'J' to be able to delete objects in bulk, I could do + the following:: + + class MyModelAdmin(admin.ModelAdmin): + ... + + def get_actions(self, request): + actions = super(MyModelAdmin, self).get_actions(request) + if request.user.username[0].upper() != 'J': + del actions['delete_selected'] + return actions + + diff --git a/tests/regressiontests/admin_views/models.py b/tests/regressiontests/admin_views/models.py index 74fc7ecf78..75b4ad2c87 100644 --- a/tests/regressiontests/admin_views/models.py +++ b/tests/regressiontests/admin_views/models.py @@ -223,7 +223,7 @@ class Subscriber(models.Model): return "%s (%s)" % (self.name, self.email) class SubscriberAdmin(admin.ModelAdmin): - actions = ['delete_selected', 'mail_admin'] + actions = ['mail_admin'] def mail_admin(self, request, selected): EmailMessage( @@ -236,7 +236,10 @@ class SubscriberAdmin(admin.ModelAdmin): class ExternalSubscriber(Subscriber): pass -def external_mail(request, selected): +class OldSubscriber(Subscriber): + pass + +def external_mail(modeladmin, request, selected): EmailMessage( 'Greetings from a function action', 'This is the test email from a function action', @@ -244,7 +247,7 @@ def external_mail(request, selected): ['to@example.com'] ).send() -def redirect_to(request, selected): +def redirect_to(modeladmin, request, selected): from django.http import HttpResponseRedirect return HttpResponseRedirect('/some-where-else/') @@ -285,6 +288,9 @@ class EmptyModelAdmin(admin.ModelAdmin): def queryset(self, request): return super(EmptyModelAdmin, self).queryset(request).filter(pk__gt=1) +class OldSubscriberAdmin(admin.ModelAdmin): + actions = None + admin.site.register(Article, ArticleAdmin) admin.site.register(CustomArticle, CustomArticleAdmin) admin.site.register(Section, save_as=True, inlines=[ArticleInline]) @@ -295,6 +301,7 @@ admin.site.register(Person, PersonAdmin) admin.site.register(Persona, PersonaAdmin) admin.site.register(Subscriber, SubscriberAdmin) admin.site.register(ExternalSubscriber, ExternalSubscriberAdmin) +admin.site.register(OldSubscriber, OldSubscriberAdmin) admin.site.register(Podcast, PodcastAdmin) admin.site.register(Parent, ParentAdmin) admin.site.register(EmptyModel, EmptyModelAdmin) diff --git a/tests/regressiontests/admin_views/tests.py b/tests/regressiontests/admin_views/tests.py index 7c54c73346..d7bce8fdcd 100644 --- a/tests/regressiontests/admin_views/tests.py +++ b/tests/regressiontests/admin_views/tests.py @@ -995,6 +995,32 @@ class AdminActionsTest(TestCase): } response = self.client.post('/test_admin/admin/admin_views/externalsubscriber/', action_data) self.failUnlessEqual(response.status_code, 302) + + def test_model_without_action(self): + "Tests a ModelAdmin without any action" + response = self.client.get('/test_admin/admin/admin_views/oldsubscriber/') + self.assertEquals(response.context["action_form"], None) + self.assert_( + '