From 3cef98620dac9eba47ac28e0bc047b4d9d494eac Mon Sep 17 00:00:00 2001 From: Marcelo Galigniana Date: Sun, 1 Oct 2023 03:50:49 +0200 Subject: [PATCH] Add description_plural to admin action decorator --- django/contrib/admin/actions.py | 9 ++++- django/contrib/admin/decorators.py | 15 ++++++-- django/contrib/admin/options.py | 39 +++++++++++-------- tests/admin_views/admin.py | 5 ++- tests/admin_views/test_actions.py | 61 +++++++++++++++++++++++++----- 5 files changed, 99 insertions(+), 30 deletions(-) diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py index eefb63837e..805ae0feca 100644 --- a/django/contrib/admin/actions.py +++ b/django/contrib/admin/actions.py @@ -7,6 +7,7 @@ from django.contrib.admin import helpers from django.contrib.admin.decorators import action from django.contrib.admin.utils import model_ngettext from django.core.exceptions import PermissionDenied +from django.db.models.query import QuerySet from django.template.response import TemplateResponse from django.utils.translation import gettext as _ from django.utils.translation import gettext_lazy @@ -14,7 +15,8 @@ from django.utils.translation import gettext_lazy @action( permissions=["delete"], - description=gettext_lazy("Delete selected %(verbose_name_plural)s"), + description="Delete", + description_plural=gettext_lazy("Delete selected %(verbose_name_plural)s"), ) def delete_selected(modeladmin, request, queryset): """ @@ -29,6 +31,11 @@ def delete_selected(modeladmin, request, queryset): opts = modeladmin.model._meta app_label = opts.app_label + # Handle the case when delete action is called from change view and + # it is a single object + if not isinstance(queryset, QuerySet): + queryset = modeladmin.model.objects.filter(id=queryset.id) + # Populate deletable_objects, a data structure of all related objects that # will also be deleted. ( diff --git a/django/contrib/admin/decorators.py b/django/contrib/admin/decorators.py index d3ff56a59a..0faa940786 100644 --- a/django/contrib/admin/decorators.py +++ b/django/contrib/admin/decorators.py @@ -1,10 +1,13 @@ -def action(function=None, *, permissions=None, description=None): +def action( + function=None, *, permissions=None, description=None, description_plural=None +): """ Conveniently add attributes to an action function:: @admin.action( permissions=['publish'], - description='Mark selected stories as published', + description='Mark story as published', + description_plural='Mark selected stories as published', ) def make_published(self, request, queryset): queryset.update(status='p') @@ -15,7 +18,8 @@ def action(function=None, *, permissions=None, description=None): def make_published(self, request, queryset): queryset.update(status='p') make_published.allowed_permissions = ['publish'] - make_published.short_description = 'Mark selected stories as published' + make_published.short_description = 'Mark story as published' + make_published.plural_description = 'Mark selected stories as published' """ def decorator(func): @@ -23,6 +27,11 @@ def action(function=None, *, permissions=None, description=None): func.allowed_permissions = permissions if description is not None: func.short_description = description + if description_plural is not None: + func.plural_description = description_plural + elif description is not None: + func.plural_description = description + return func if function is None: diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 678d664b0d..9565cbc24d 100644 --- a/django/contrib/admin/options.py +++ b/django/contrib/admin/options.py @@ -1032,16 +1032,21 @@ class ModelAdmin(BaseModelAdmin): return checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk)) @staticmethod - def _get_action_description(func, name): + def _get_action_description(func, name, change=False): try: - return func.short_description + if change: + return func.short_description + else: + return func.plural_description except AttributeError: return capfirst(name.replace("_", " ")) - def _get_base_actions(self): + def _get_base_actions(self, change=False): """Return the list of actions, prior to any request-based filtering.""" actions = [] - base_actions = (self.get_action(action) for action in self.actions or []) + base_actions = ( + self.get_action(action, change) for action in self.actions or [] + ) # get_action might have returned None, so filter any of those out. base_actions = [action for action in base_actions if action] base_action_names = {name for _, name, _ in base_actions} @@ -1050,7 +1055,7 @@ class ModelAdmin(BaseModelAdmin): for name, func in self.admin_site.actions: if name in base_action_names: continue - description = self._get_action_description(func, name) + description = self._get_action_description(func, name, change) actions.append((func, name, description)) # Add actions from this ModelAdmin. actions.extend(base_actions) @@ -1072,7 +1077,7 @@ class ModelAdmin(BaseModelAdmin): filtered_actions.append(action) return filtered_actions - def get_actions(self, request): + def get_actions(self, request, change=False): """ Return a dictionary mapping the names of all actions for this ModelAdmin to a tuple of (callable, name, description) for each action. @@ -1081,21 +1086,25 @@ class ModelAdmin(BaseModelAdmin): # this page. if self.actions is None or IS_POPUP_VAR in request.GET: return {} - actions = self._filter_actions_by_permissions(request, self._get_base_actions()) + actions = self._filter_actions_by_permissions( + request, self._get_base_actions(change) + ) return {name: (func, name, desc) for func, name, desc in actions} - def get_action_choices(self, request, default_choices=models.BLANK_CHOICE_DASH): + def get_action_choices( + self, request, change=False, default_choices=models.BLANK_CHOICE_DASH + ): """ Return a list of choices for use in a form object. Each choice is a tuple (name, description). """ choices = [] + default_choices - for func, name, description in self.get_actions(request).values(): + for func, name, description in self.get_actions(request, change).values(): choice = (name, description % model_format_dict(self.opts)) choices.append(choice) return choices - def get_action(self, action): + def get_action(self, action, change): """ Return a given action from a parameter, which can either be a callable, or the name of a method on the ModelAdmin. Return is a tuple of @@ -1119,7 +1128,7 @@ class ModelAdmin(BaseModelAdmin): except KeyError: return None - description = self._get_action_description(func, action) + description = self._get_action_description(func, action, change) return func, action, description def get_list_display(self, request): @@ -1948,11 +1957,9 @@ class ModelAdmin(BaseModelAdmin): # Build the action form and populate it with available actions. if actions and not add: action_form = self.action_form(auto_id=None) - action_choices = self.get_action_choices(request) - # Remove "delete" action; change view already has a button for - # that action. - action_choices.pop(1) - action_form.fields["action"].choices = action_choices + action_form.fields["action"].choices = self.get_action_choices( + request, change=True + ) media += action_form.media else: action_form = None diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index ec623ec82b..1fd76b5c52 100644 --- a/tests/admin_views/admin.py +++ b/tests/admin_views/admin.py @@ -411,7 +411,10 @@ def redirect_to(modeladmin, request, selected): return HttpResponseRedirect("/some-where-else/") -@admin.action(description="Download subscription") +@admin.action( + description="Download subscription", + description_plural="Download selected subscriptions", +) def download(modeladmin, request, selected): from django.db.models.query import QuerySet diff --git a/tests/admin_views/test_actions.py b/tests/admin_views/test_actions.py index 8bbfeb7a37..809a0a8261 100644 --- a/tests/admin_views/test_actions.py +++ b/tests/admin_views/test_actions.py @@ -299,7 +299,7 @@ subscribers - + """, html=True, @@ -562,12 +562,8 @@ class AdminDetailActionsTest(TestCase): def test_available_detail_actions(self): """ - - Action 1 has not description. - - Action 2 has custom description. - - Action 3 ("Detail download") is not displayed because user does not - have permission. - - "Delete" action is not displayed in change view because already - exists a button for it. + 'Download' action with singular description and + 'Delete' action present in dropdown. """ response = self.client.get( @@ -578,15 +574,40 @@ class AdminDetailActionsTest(TestCase): response, """