Browse Source

Fixed #16117 -- Added decorators for admin action and display functions.

Refs #25134, #32099.
Nick Pope 4 years ago
parent
commit
9204485396

+ 5 - 5
django/contrib/admin/__init__.py

@@ -1,4 +1,4 @@
-from django.contrib.admin.decorators import register
+from django.contrib.admin.decorators import action, display, register
 from django.contrib.admin.filters import (
     AllValuesFieldListFilter, BooleanFieldListFilter, ChoicesFieldListFilter,
     DateFieldListFilter, EmptyFieldListFilter, FieldListFilter, ListFilter,
@@ -11,10 +11,10 @@ from django.contrib.admin.sites import AdminSite, site
 from django.utils.module_loading import autodiscover_modules
 
 __all__ = [
-    "register", "ModelAdmin", "HORIZONTAL", "VERTICAL", "StackedInline",
-    "TabularInline", "AdminSite", "site", "ListFilter", "SimpleListFilter",
-    "FieldListFilter", "BooleanFieldListFilter", "RelatedFieldListFilter",
-    "ChoicesFieldListFilter", "DateFieldListFilter",
+    "action", "display", "register", "ModelAdmin", "HORIZONTAL", "VERTICAL",
+    "StackedInline", "TabularInline", "AdminSite", "site", "ListFilter",
+    "SimpleListFilter", "FieldListFilter", "BooleanFieldListFilter",
+    "RelatedFieldListFilter", "ChoicesFieldListFilter", "DateFieldListFilter",
     "AllValuesFieldListFilter", "EmptyFieldListFilter",
     "RelatedOnlyFieldListFilter", "autodiscover",
 ]

+ 5 - 4
django/contrib/admin/actions.py

@@ -4,12 +4,17 @@ Built-in, globally-available admin actions.
 
 from django.contrib import messages
 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.template.response import TemplateResponse
 from django.utils.translation import gettext as _, gettext_lazy
 
 
+@action(
+    permissions=['delete'],
+    description=gettext_lazy('Delete selected %(verbose_name_plural)s'),
+)
 def delete_selected(modeladmin, request, queryset):
     """
     Default action which deletes the selected objects.
@@ -73,7 +78,3 @@ def delete_selected(modeladmin, request, queryset):
         "admin/%s/delete_selected_confirmation.html" % app_label,
         "admin/delete_selected_confirmation.html"
     ], context)
-
-
-delete_selected.allowed_permissions = ('delete',)
-delete_selected.short_description = gettext_lazy("Delete selected %(verbose_name_plural)s")

+ 73 - 0
django/contrib/admin/decorators.py

@@ -1,3 +1,76 @@
+def action(function=None, *, permissions=None, description=None):
+    """
+    Conveniently add attributes to an action function::
+
+        @admin.action(
+            permissions=['publish'],
+            description='Mark selected stories as published',
+        )
+        def make_published(self, request, queryset):
+            queryset.update(status='p')
+
+    This is equivalent to setting some attributes (with the original, longer
+    names) on the function directly::
+
+        def make_published(self, request, queryset):
+            queryset.update(status='p')
+        make_published.allowed_permissions = ['publish']
+        make_published.short_description = 'Mark selected stories as published'
+    """
+    def decorator(func):
+        if permissions is not None:
+            func.allowed_permissions = permissions
+        if description is not None:
+            func.short_description = description
+        return func
+    if function is None:
+        return decorator
+    else:
+        return decorator(function)
+
+
+def display(function=None, *, boolean=None, ordering=None, description=None, empty_value=None):
+    """
+    Conveniently add attributes to a display function::
+
+        @admin.display(
+            boolean=True,
+            ordering='-publish_date',
+            description='Is Published?',
+        )
+        def is_published(self, obj):
+            return obj.publish_date is not None
+
+    This is equivalent to setting some attributes (with the original, longer
+    names) on the function directly::
+
+        def is_published(self, obj):
+            return obj.publish_date is not None
+        is_published.boolean = True
+        is_published.admin_order_field = '-publish_date'
+        is_published.short_description = 'Is Published?'
+    """
+    def decorator(func):
+        if boolean is not None and empty_value is not None:
+            raise ValueError(
+                'The boolean and empty_value arguments to the @display '
+                'decorator are mutually exclusive.'
+            )
+        if boolean is not None:
+            func.boolean = boolean
+        if ordering is not None:
+            func.admin_order_field = ordering
+        if description is not None:
+            func.short_description = description
+        if empty_value is not None:
+            func.empty_value_display = empty_value
+        return func
+    if function is None:
+        return decorator
+    else:
+        return decorator(function)
+
+
 def register(*models, site=None):
     """
     Register the given model(s) classes and wrapped ModelAdmin class with

+ 2 - 1
django/contrib/admin/options.py

@@ -12,6 +12,7 @@ from django.contrib.admin import helpers, widgets
 from django.contrib.admin.checks import (
     BaseModelAdminChecks, InlineModelAdminChecks, ModelAdminChecks,
 )
+from django.contrib.admin.decorators import display
 from django.contrib.admin.exceptions import DisallowedModelAdminToField
 from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
 from django.contrib.admin.utils import (
@@ -848,12 +849,12 @@ class ModelAdmin(BaseModelAdmin):
             action_flag=DELETION,
         )
 
+    @display(description=mark_safe('<input type="checkbox" id="action-toggle">'))
     def action_checkbox(self, obj):
         """
         A list_display column containing a checkbox widget.
         """
         return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk))
-    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle">')
 
     @staticmethod
     def _get_action_description(func, name):

+ 10 - 6
docs/intro/tutorial07.txt

@@ -228,22 +228,26 @@ of an arbitrary method is not supported. Also note that the column header for
 underscores replaced with spaces), and that each line contains the string
 representation of the output.
 
-You can improve that by giving that method (in :file:`polls/models.py`) a few
-attributes, as follows:
+You can improve that by using the :func:`~django.contrib.admin.display`
+decorator on that method (in :file:`polls/models.py`), as follows:
 
 .. code-block:: python
     :caption: polls/models.py
 
+    from django.contrib import admin
+
     class Question(models.Model):
         # ...
+        @admin.display(
+            boolean=True,
+            ordering='pub_date',
+            description='Published recently?',
+        )
         def was_published_recently(self):
             now = timezone.now()
             return now - datetime.timedelta(days=1) <= self.pub_date <= now
-        was_published_recently.admin_order_field = 'pub_date'
-        was_published_recently.boolean = True
-        was_published_recently.short_description = 'Published recently?'
 
-For more information on these method properties, see
+For more information on the properties configurable via the decorator, see
 :attr:`~django.contrib.admin.ModelAdmin.list_display`.
 
 Edit your :file:`polls/admin.py` file again and add an improvement to the

+ 72 - 15
docs/ref/contrib/admin/actions.txt

@@ -99,18 +99,32 @@ That's actually all there is to writing an action! However, we'll take one
 more optional-but-useful step and give the action a "nice" title in the admin.
 By default, this action would appear in the action list as "Make published" --
 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::
+can provide a better, more human-friendly name by using the
+:func:`~django.contrib.admin.action` decorator on the ``make_published``
+function::
 
+    from django.contrib import admin
+
+    ...
+
+    @admin.action(description='Mark selected stories as published')
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
-    make_published.short_description = "Mark selected stories as published"
 
 .. note::
 
-    This might look familiar; the admin's ``list_display`` option uses the
-    same technique to provide human-readable descriptions for callback
-    functions registered there, too.
+    This might look familiar; the admin's
+    :attr:`~django.contrib.admin.ModelAdmin.list_display` option uses a similar
+    technique with the :func:`~django.contrib.admin.display` decorator to
+    provide human-readable descriptions for callback functions registered
+    there, too.
+
+.. versionchanged:: 3.2
+
+    The ``description`` argument to the :func:`~django.contrib.admin.action`
+    decorator is equivalent to setting the ``short_description`` attribute on
+    the action function directly in previous versions. Setting the attribute
+    directly is still supported for backward compatibility.
 
 Adding actions to the :class:`ModelAdmin`
 -----------------------------------------
@@ -122,9 +136,9 @@ the action and its registration would look like::
     from django.contrib import admin
     from myapp.models import Article
 
+    @admin.action(description='Mark selected stories as published')
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
-    make_published.short_description = "Mark selected stories as published"
 
     class ArticleAdmin(admin.ModelAdmin):
         list_display = ['title', 'status']
@@ -171,9 +185,9 @@ You can do it like this::
 
         actions = ['make_published']
 
+        @admin.action(description='Mark selected stories as published')
         def make_published(self, request, queryset):
             queryset.update(status='p')
-        make_published.short_description = "Mark selected stories as published"
 
 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
@@ -364,20 +378,20 @@ Setting permissions for actions
 -------------------------------
 
 Actions may limit their availability to users with specific permissions by
-setting an ``allowed_permissions`` attribute on the action function::
+wrapping the action function with the :func:`~django.contrib.admin.action`
+decorator and passing the ``permissions`` argument::
 
+    @admin.action(permissions=['change'])
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
-    make_published.allowed_permissions = ('change',)
 
 The ``make_published()`` action will only be available to users that pass the
 :meth:`.ModelAdmin.has_change_permission` check.
 
-If ``allowed_permissions`` has more than one permission, the action will be
-available as long as the user passes at least one of the checks.
+If ``permissions`` has more than one permission, the action will be available
+as long as the user passes at least one of the checks.
 
-Available values for ``allowed_permissions`` and the corresponding method
-checks are:
+Available values for ``permissions`` and the corresponding method checks are:
 
 - ``'add'``: :meth:`.ModelAdmin.has_add_permission`
 - ``'change'``: :meth:`.ModelAdmin.has_change_permission`
@@ -395,12 +409,55 @@ For example::
     class ArticleAdmin(admin.ModelAdmin):
         actions = ['make_published']
 
+        @admin.action(permissions=['publish'])
         def make_published(self, request, queryset):
             queryset.update(status='p')
-        make_published.allowed_permissions = ('publish',)
 
         def has_publish_permission(self, request):
             """Does the user have the publish permission?"""
             opts = self.opts
             codename = get_permission_codename('publish', opts)
             return request.user.has_perm('%s.%s' % (opts.app_label, codename))
+
+.. versionchanged:: 3.2
+
+    The ``permissions`` argument to the :func:`~django.contrib.admin.action`
+    decorator is equivalent to setting the ``allowed_permissions`` attribute on
+    the action function directly in previous versions. Setting the attribute
+    directly is still supported for backward compatibility.
+
+The ``action`` decorator
+========================
+
+.. function:: action(*, permissions=None, description=None)
+
+    .. versionadded:: 3.2
+
+    This decorator can be used for setting specific attributes on custom action
+    functions that can be used with
+    :attr:`~django.contrib.admin.ModelAdmin.actions`::
+
+        @admin.action(
+            permissions=['publish'],
+            description='Mark selected stories as published',
+        )
+        def make_published(self, request, queryset):
+            queryset.update(status='p')
+
+    This is equivalent to setting some attributes (with the original, longer
+    names) on the function directly::
+
+        def make_published(self, request, queryset):
+            queryset.update(status='p')
+        make_published.allowed_permissions = ['publish']
+        make_published.short_description = 'Mark selected stories as published'
+
+    Use of this decorator is not compulsory to make an action function, but it
+    can be useful to use it without arguments as a marker in your source to
+    identify the purpose of the function::
+
+        @admin.action
+        def make_inactive(self, request, queryset):
+            queryset.update(is_active=False)
+
+    In this case it will add no attributes to the function.

+ 151 - 61
docs/ref/contrib/admin/index.txt

@@ -256,10 +256,17 @@ subclass::
         class AuthorAdmin(admin.ModelAdmin):
             fields = ('name', 'title', 'view_birth_date')
 
+            @admin.display(empty_value='???')
             def view_birth_date(self, obj):
                 return obj.birth_date
 
-            view_birth_date.empty_value_display = '???'
+    .. versionchanged:: 3.2
+
+        The ``empty_value`` argument to the
+        :func:`~django.contrib.admin.display` decorator is equivalent to
+        setting the ``empty_value_display`` attribute on the display function
+        directly in previous versions. Setting the attribute directly is still
+        supported for backward compatibility.
 
 .. attribute:: ModelAdmin.exclude
 
@@ -551,7 +558,9 @@ subclass::
     If you don't set ``list_display``, the admin site will display a single
     column that displays the ``__str__()`` representation of each object.
 
-    There are four types of values that can be used in ``list_display``:
+    There are four types of values that can be used in ``list_display``. All
+    but the simplest may use the  :func:`~django.contrib.admin.display`
+    decorator is used to customize how the field is presented:
 
     * The name of a model field. For example::
 
@@ -560,9 +569,9 @@ subclass::
 
     * A callable that accepts one argument, the model instance. For example::
 
+          @admin.display(description='Name')
           def upper_case_name(obj):
               return ("%s %s" % (obj.first_name, obj.last_name)).upper()
-          upper_case_name.short_description = 'Name'
 
           class PersonAdmin(admin.ModelAdmin):
               list_display = (upper_case_name,)
@@ -573,9 +582,9 @@ subclass::
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('upper_case_name',)
 
+              @admin.display(description='Name')
               def upper_case_name(self, obj):
                   return ("%s %s" % (obj.first_name, obj.last_name)).upper()
-              upper_case_name.short_description = 'Name'
 
     * A string representing a model attribute or method (without any required
       arguments). For example::
@@ -587,9 +596,9 @@ subclass::
               name = models.CharField(max_length=50)
               birthday = models.DateField()
 
+              @admin.display(description='Birth decade')
               def decade_born_in(self):
                   return '%d’s' % (self.birthday.year // 10 * 10)
-              decade_born_in.short_description = 'Birth decade'
 
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'decade_born_in')
@@ -624,6 +633,7 @@ subclass::
               last_name = models.CharField(max_length=50)
               color_code = models.CharField(max_length=6)
 
+              @admin.display
               def colored_name(self):
                   return format_html(
                       '<span style="color: #{};">{} {}</span>',
@@ -637,7 +647,17 @@ subclass::
 
     * As some examples have already demonstrated, when using a callable, a
       model method, or a ``ModelAdmin`` method, you can customize the column's
-      title by adding a ``short_description`` attribute to the callable.
+      title by wrapping the callable with the
+      :func:`~django.contrib.admin.display` decorator and passing the
+      ``description`` argument.
+
+      .. versionchanged:: 3.2
+
+          The ``description`` argument to the
+          :func:`~django.contrib.admin.display` decorator is equivalent to
+          setting the ``short_description`` attribute on the display function
+          directly in previous versions. Setting the attribute directly is
+          still supported for backward compatibility.
 
     * If the value of a field is ``None``, an empty string, or an iterable
       without elements, Django will display ``-`` (a dash). You can override
@@ -657,17 +677,23 @@ subclass::
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'birth_date_view')
 
+              @admin.display(empty_value='unknown')
               def birth_date_view(self, obj):
                    return obj.birth_date
 
-              birth_date_view.empty_value_display = 'unknown'
+      .. versionchanged:: 3.2
+
+          The ``empty_value`` argument to the
+          :func:`~django.contrib.admin.display` decorator is equivalent to
+          setting the ``empty_value_display`` attribute on the display function
+          directly in previous versions. Setting the attribute directly is
+          still supported for backward compatibility.
 
     * If the string given is a method of the model, ``ModelAdmin`` or a
       callable that returns ``True``, ``False``, or ``None``, Django will
-      display a pretty "yes", "no", or "unknown" icon if you give the method a
-      ``boolean`` attribute whose value is ``True``.
-
-      Here's a full example model::
+      display a pretty "yes", "no", or "unknown" icon if you wrap the method
+      with the :func:`~django.contrib.admin.display` decorator passing the
+      ``boolean`` argument with the value set to ``True``::
 
           from django.contrib import admin
           from django.db import models
@@ -676,13 +702,21 @@ subclass::
               first_name = models.CharField(max_length=50)
               birthday = models.DateField()
 
+              @admin.display(boolean=True)
               def born_in_fifties(self):
                   return 1950 <= self.birthday.year < 1960
-              born_in_fifties.boolean = True
 
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'born_in_fifties')
 
+      .. versionchanged:: 3.2
+
+          The ``boolean`` argument to the
+          :func:`~django.contrib.admin.display` decorator is equivalent to
+          setting the ``boolean`` attribute on the display function directly in
+          previous versions. Setting the attribute directly is still supported
+          for backward compatibility.
+
     * The ``__str__()`` method is just as valid in ``list_display`` as any
       other model method, so it's perfectly OK to do this::
 
@@ -692,44 +726,42 @@ subclass::
       fields can't be used in sorting (because Django does all the sorting
       at the database level).
 
-      However, if an element of ``list_display`` represents a certain
-      database field, you can indicate this fact by setting the
-      ``admin_order_field`` attribute of the item.
-
-      For example::
+      However, if an element of ``list_display`` represents a certain database
+      field, you can indicate this fact by using the
+      :func:`~django.contrib.admin.display` decorator on the method, passing
+      the ``ordering`` argument::
 
-        from django.contrib import admin
-        from django.db import models
-        from django.utils.html import format_html
-
-        class Person(models.Model):
-            first_name = models.CharField(max_length=50)
-            color_code = models.CharField(max_length=6)
+          from django.contrib import admin
+          from django.db import models
+          from django.utils.html import format_html
 
-            def colored_first_name(self):
-                return format_html(
-                    '<span style="color: #{};">{}</span>',
-                    self.color_code,
-                    self.first_name,
-                )
+          class Person(models.Model):
+              first_name = models.CharField(max_length=50)
+              color_code = models.CharField(max_length=6)
 
-            colored_first_name.admin_order_field = 'first_name'
+              @admin.display(ordering='first_name')
+              def colored_first_name(self):
+                  return format_html(
+                      '<span style="color: #{};">{}</span>',
+                      self.color_code,
+                      self.first_name,
+                  )
 
-        class PersonAdmin(admin.ModelAdmin):
-            list_display = ('first_name', 'colored_first_name')
+          class PersonAdmin(admin.ModelAdmin):
+              list_display = ('first_name', 'colored_first_name')
 
       The above will tell Django to order by the ``first_name`` field when
       trying to sort by ``colored_first_name`` in the admin.
 
-      To indicate descending order with ``admin_order_field`` you can use a
-      hyphen prefix on the field name. Using the above example, this would
-      look like::
+      To indicate descending order with the ``ordering`` argument you can use a
+      hyphen prefix on the field name. Using the above example, this would look
+      like::
 
-          colored_first_name.admin_order_field = '-first_name'
+          @admin.display(ordering='-first_name')
 
-      ``admin_order_field`` supports query lookups to sort by values on related
-      models. This example includes an "author first name" column in the list
-      display and allows sorting it by first name::
+      The ``ordering`` argument supports query lookups to sort by values on
+      related models. This example includes an "author first name" column in
+      the list display and allows sorting it by first name::
 
           class Blog(models.Model):
               title = models.CharField(max_length=255)
@@ -738,13 +770,12 @@ subclass::
           class BlogAdmin(admin.ModelAdmin):
               list_display = ('title', 'author', 'author_first_name')
 
+              @admin.display(ordering='author__first_name')
               def author_first_name(self, obj):
                   return obj.author.first_name
 
-              author_first_name.admin_order_field = 'author__first_name'
-
-      :doc:`Query expressions </ref/models/expressions>` may be used in
-      ``admin_order_field``. For example::
+      :doc:`Query expressions </ref/models/expressions>` may be used with the
+      ``ordering`` argument::
 
           from django.db.models import Value
           from django.db.models.functions import Concat
@@ -753,32 +784,47 @@ subclass::
               first_name = models.CharField(max_length=50)
               last_name = models.CharField(max_length=50)
 
+              @admin.display(ordering=Concat('first_name', Value(' '), 'last_name'))
               def full_name(self):
                   return self.first_name + ' ' + self.last_name
-              full_name.admin_order_field = Concat('first_name', Value(' '), 'last_name')
 
-    * Elements of ``list_display`` can also be properties. Please note however,
-      that due to the way properties work in Python, setting
-      ``short_description`` or ``admin_order_field`` on a property is only
-      possible when using the ``property()`` function and **not** with the
-      ``@property`` decorator.
+      .. versionchanged:: 3.2
+
+          The ``ordering`` argument to the
+          :func:`~django.contrib.admin.display` decorator is equivalent to
+          setting the ``admin_order_field`` attribute on the display function
+          directly in previous versions. Setting the attribute directly is
+          still supported for backward compatibility.
 
-      For example::
+    * Elements of ``list_display`` can also be properties::
 
           class Person(models.Model):
               first_name = models.CharField(max_length=50)
               last_name = models.CharField(max_length=50)
 
-              def my_property(self):
+              @property
+              @admin.display(
+                  ordering='last_name',
+                  description='Full name of the person',
+              )
+              def full_name(self):
                   return self.first_name + ' ' + self.last_name
-              my_property.short_description = "Full name of the person"
-              my_property.admin_order_field = 'last_name'
-
-              full_name = property(my_property)
 
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('full_name',)
 
+      Note that ``@property`` must be above ``@display``. If you're using the
+      old way -- setting the display-related attributes directly rather than
+      using the :func:`~django.contrib.admin.display` decorator --  be aware
+      that the ``property()`` function and **not** the ``@property`` decorator
+      must be used::
+
+          def my_property(self):
+              return self.first_name + ' ' + self.last_name
+          my_property.short_description = "Full name of the person"
+          my_property.admin_order_field = 'last_name'
+
+          full_name = property(my_property)
 
     * The field names in ``list_display`` will also appear as CSS classes in
       the HTML output, in the form of ``column-<field_name>`` on each ``<th>``
@@ -1239,6 +1285,8 @@ subclass::
         class PersonAdmin(admin.ModelAdmin):
             readonly_fields = ('address_report',)
 
+            # description functions like a model field's verbose_name
+            @admin.display(description='Address')
             def address_report(self, instance):
                 # assuming get_full_address() returns a list of strings
                 # for each line of the address and you want to separate each
@@ -1249,9 +1297,6 @@ subclass::
                     ((line,) for line in instance.get_full_address()),
                 ) or mark_safe("<span class='errors'>I can't determine this address.</span>")
 
-            # short_description functions like a model field's verbose_name
-            address_report.short_description = "Address"
-
 .. attribute:: ModelAdmin.save_as
 
     Set ``save_as`` to enable a "save as new" feature on admin change forms.
@@ -1360,8 +1405,9 @@ subclass::
 .. attribute:: ModelAdmin.sortable_by
 
     By default, the change list page allows sorting by all model fields (and
-    callables that have the ``admin_order_field`` property) specified in
-    :attr:`list_display`.
+    callables that use the ``ordering`` argument to the
+    :func:`~django.contrib.admin.display` decorator or have the
+    ``admin_order_field`` attribute) specified in :attr:`list_display`.
 
     If you want to disable sorting for some columns, set ``sortable_by`` to
     a collection (e.g. ``list``, ``tuple``, or ``set``) of the subset of
@@ -3337,6 +3383,50 @@ The action in the examples above match the last part of the URL names for
 object which has an ``app_label`` and ``model_name`` attributes and is usually
 supplied by the admin views for the current model.
 
+The ``display`` decorator
+=========================
+
+.. function:: display(*, boolean=None, ordering=None, description=None, empty_value=None)
+
+    .. versionadded:: 3.2
+
+    This decorator can be used for setting specific attributes on custom
+    display functions that can be used with
+    :attr:`~django.contrib.admin.ModelAdmin.list_display` or
+    :attr:`~django.contrib.admin.ModelAdmin.readonly_fields`::
+
+        @admin.display(
+            boolean=True,
+            ordering='-publish_date',
+            description='Is Published?',
+        )
+        def is_published(self, obj):
+            return obj.publish_date is not None
+
+    This is equivalent to setting some attributes (with the original, longer
+    names) on the function directly::
+
+        def is_published(self, obj):
+            return obj.publish_date is not None
+        is_published.boolean = True
+        is_published.admin_order_field = '-publish_date'
+        is_published.short_description = 'Is Published?'
+
+    Also note that the ``empty_value`` decorator parameter maps to the
+    ``empty_value_display`` attribute assigned directly to the function. It
+    cannot be used in conjunction with ``boolean`` -- they are mutually
+    exclusive.
+
+    Use of this decorator is not compulsory to make a display function, but it
+    can be useful to use it without arguments as a marker in your source to
+    identify the purpose of the function::
+
+        @admin.display
+        def published_year(self, obj):
+            return obj.publish_date.year
+
+    In this case it will add no attributes to the function.
+
 .. currentmodule:: django.contrib.admin.views.decorators
 
 The ``staff_member_required`` decorator

+ 22 - 0
docs/releases/3.2.txt

@@ -141,6 +141,28 @@ Django </topics/cache>`.
 
 .. _pymemcache: https://pypi.org/project/pymemcache/
 
+New decorators for the admin site
+---------------------------------
+
+The new :func:`~django.contrib.admin.display` decorator allows for easily
+adding options to custom display functions that can be used with
+:attr:`~django.contrib.admin.ModelAdmin.list_display` or
+:attr:`~django.contrib.admin.ModelAdmin.readonly_fields`.
+
+Likewise, the new :func:`~django.contrib.admin.action` decorator allows for
+easily adding options to action functions that can be used with
+:attr:`~django.contrib.admin.ModelAdmin.actions`.
+
+Using the ``@display`` decorator has the advantage that it is now
+possible to use the ``@property`` decorator when needing to specify attributes
+on the custom method. Prior to this it was necessary to use the ``property()``
+function instead after assigning the required attributes to the method.
+
+Using decorators has the advantage that these options are more discoverable as
+they can be suggested by completion utilities in code editors. They are merely
+a convenience and still set the same attributes on the functions under the
+hood.
+
 Minor features
 --------------
 

+ 6 - 4
docs/topics/i18n/translation.txt

@@ -389,12 +389,14 @@ verbose names Django performs by looking at the model's class name::
             verbose_name = _('my thing')
             verbose_name_plural = _('my things')
 
-Model methods ``short_description`` attribute values
-~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
+Model methods ``description`` argument to the ``@display`` decorator
+~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
 
 For model methods, you can provide translations to Django and the admin site
-with the ``short_description`` attribute::
+with the ``description`` argument to the :func:`~django.contrib.admin.display`
+decorator::
 
+    from django.contrib import admin
     from django.db import models
     from django.utils.translation import gettext_lazy as _
 
@@ -406,9 +408,9 @@ with the ``short_description`` attribute::
             verbose_name=_('kind'),
         )
 
+        @admin.display(description=_('Is it a mouse?'))
         def is_mouse(self):
             return self.kind.type == MOUSE_TYPE
-        is_mouse.short_description = _('Is it a mouse?')
 
 Working with lazy translation objects
 -------------------------------------

+ 2 - 1
tests/admin_changelist/admin.py

@@ -19,6 +19,7 @@ class EventAdmin(admin.ModelAdmin):
     date_hierarchy = 'date'
     list_display = ['event_date_func']
 
+    @admin.display
     def event_date_func(self, event):
         return event.date
 
@@ -171,6 +172,6 @@ class EmptyValueChildAdmin(admin.ModelAdmin):
     empty_value_display = '-empty-'
     list_display = ('name', 'age_display', 'age')
 
+    @admin.display(empty_value='&dagger;')
     def age_display(self, obj):
         return obj.age
-    age_display.empty_value_display = '&dagger;'

+ 4 - 0
tests/admin_checks/tests.py

@@ -692,6 +692,7 @@ class SystemChecksTestCase(SimpleTestCase):
         self.assertEqual(errors, [])
 
     def test_readonly_on_method(self):
+        @admin.display
         def my_function(obj):
             pass
 
@@ -705,6 +706,7 @@ class SystemChecksTestCase(SimpleTestCase):
         class SongAdmin(admin.ModelAdmin):
             readonly_fields = ("readonly_method_on_modeladmin",)
 
+            @admin.display
             def readonly_method_on_modeladmin(self, obj):
                 pass
 
@@ -717,6 +719,7 @@ class SystemChecksTestCase(SimpleTestCase):
 
             def __getattr__(self, item):
                 if item == "dynamic_method":
+                    @admin.display
                     def method(obj):
                         pass
                     return method
@@ -777,6 +780,7 @@ class SystemChecksTestCase(SimpleTestCase):
 
     def test_extra(self):
         class SongAdmin(admin.ModelAdmin):
+            @admin.display
             def awesome_song(self, instance):
                 if instance.title == "Born to Run":
                     return "Best Ever!"

+ 2 - 1
tests/admin_utils/models.py

@@ -1,3 +1,4 @@
+from django.contrib import admin
 from django.db import models
 from django.utils.translation import gettext_lazy as _
 
@@ -28,9 +29,9 @@ class Article(models.Model):
     def test_from_model(self):
         return "nothing"
 
+    @admin.display(description='not What you Expect')
     def test_from_model_with_override(self):
         return "nothing"
-    test_from_model_with_override.short_description = "not What you Expect"
 
 
 class ArticleProxy(Article):

+ 5 - 6
tests/admin_utils/tests.py

@@ -3,6 +3,7 @@ from decimal import Decimal
 
 from django import forms
 from django.conf import settings
+from django.contrib import admin
 from django.contrib.admin import helpers
 from django.contrib.admin.utils import (
     NestedObjects, display_for_field, display_for_value, flatten,
@@ -293,9 +294,9 @@ class UtilsTests(SimpleTestCase):
         self.assertEqual(label_for_field('site_id', Article), 'Site id')
 
         class MockModelAdmin:
+            @admin.display(description='not Really the Model')
             def test_from_model(self, obj):
                 return "nothing"
-            test_from_model.short_description = "not Really the Model"
 
         self.assertEqual(
             label_for_field("test_from_model", Article, model_admin=MockModelAdmin),
@@ -323,13 +324,11 @@ class UtilsTests(SimpleTestCase):
             label_for_field('nonexistent', Article, form=ArticleForm()),
 
     def test_label_for_property(self):
-        # NOTE: cannot use @property decorator, because of
-        # AttributeError: 'property' object has no attribute 'short_description'
         class MockModelAdmin:
-            def my_property(self):
+            @property
+            @admin.display(description='property short description')
+            def test_from_property(self):
                 return "this if from property"
-            my_property.short_description = 'property short description'
-            test_from_property = property(my_property)
 
         self.assertEqual(
             label_for_field("test_from_property", Article, model_admin=MockModelAdmin),

+ 27 - 27
tests/admin_views/admin.py

@@ -49,6 +49,7 @@ from .models import (
 )
 
 
+@admin.display(ordering='date')
 def callable_year(dt_value):
     try:
         return dt_value.year
@@ -56,9 +57,6 @@ def callable_year(dt_value):
         return None
 
 
-callable_year.admin_order_field = 'date'
-
-
 class ArticleInline(admin.TabularInline):
     model = Article
     fk_name = 'section'
@@ -138,25 +136,24 @@ class ArticleAdmin(ArticleAdminWithExtraUrl):
 
     # These orderings aren't particularly useful but show that expressions can
     # be used for admin_order_field.
+    @admin.display(ordering=models.F('date') + datetime.timedelta(days=3))
     def order_by_expression(self, obj):
         return obj.model_year
-    order_by_expression.admin_order_field = models.F('date') + datetime.timedelta(days=3)
 
+    @admin.display(ordering=models.F('date'))
     def order_by_f_expression(self, obj):
         return obj.model_year
-    order_by_f_expression.admin_order_field = models.F('date')
 
+    @admin.display(ordering=models.F('date').asc(nulls_last=True))
     def order_by_orderby_expression(self, obj):
         return obj.model_year
-    order_by_orderby_expression.admin_order_field = models.F('date').asc(nulls_last=True)
 
     def changelist_view(self, request):
         return super().changelist_view(request, extra_context={'extra_var': 'Hello!'})
 
+    @admin.display(ordering='date', description=None)
     def modeladmin_year(self, obj):
         return obj.date.year
-    modeladmin_year.admin_order_field = 'date'
-    modeladmin_year.short_description = None
 
     def delete_model(self, request, obj):
         EmailMessage(
@@ -216,6 +213,7 @@ class ThingAdmin(admin.ModelAdmin):
 class InquisitionAdmin(admin.ModelAdmin):
     list_display = ('leader', 'country', 'expected', 'sketch')
 
+    @admin.display
     def sketch(self, obj):
         # A method with the same name as a reverse accessor.
         return 'list-display-sketch'
@@ -280,6 +278,7 @@ class SubscriberAdmin(admin.ModelAdmin):
         SubscriberAdmin.overridden = True
         super().delete_queryset(request, queryset)
 
+    @admin.action
     def mail_admin(self, request, selected):
         EmailMessage(
             'Greetings from a ModelAdmin action',
@@ -289,6 +288,7 @@ class SubscriberAdmin(admin.ModelAdmin):
         ).send()
 
 
+@admin.action(description='External mail (Another awesome action)')
 def external_mail(modeladmin, request, selected):
     EmailMessage(
         'Greetings from a function action',
@@ -298,32 +298,23 @@ def external_mail(modeladmin, request, selected):
     ).send()
 
 
-external_mail.short_description = 'External mail (Another awesome action)'
-
-
+@admin.action(description='Redirect to (Awesome action)')
 def redirect_to(modeladmin, request, selected):
     from django.http import HttpResponseRedirect
     return HttpResponseRedirect('/some-where-else/')
 
 
-redirect_to.short_description = 'Redirect to (Awesome action)'
-
-
+@admin.action(description='Download subscription')
 def download(modeladmin, request, selected):
     buf = StringIO('This is the content of the file')
     return StreamingHttpResponse(FileWrapper(buf))
 
 
-download.short_description = 'Download subscription'
-
-
+@admin.action(description='No permission to run')
 def no_perm(modeladmin, request, selected):
     return HttpResponse(content='No permission to perform this action', status=403)
 
 
-no_perm.short_description = 'No permission to run'
-
-
 class ExternalSubscriberAdmin(admin.ModelAdmin):
     actions = [redirect_to, external_mail, download, no_perm]
 
@@ -441,6 +432,7 @@ class LinkInline(admin.TabularInline):
 
     readonly_fields = ("posted", "multiline", "readonly_link_content")
 
+    @admin.display
     def multiline(self, instance):
         return "InlineMultiline\ntest\nstring"
 
@@ -501,19 +493,22 @@ class PostAdmin(admin.ModelAdmin):
         LinkInline
     ]
 
+    @admin.display
     def coolness(self, instance):
         if instance.pk:
             return "%d amount of cool." % instance.pk
         else:
             return "Unknown coolness."
 
+    @admin.display(description='Value in $US')
     def value(self, instance):
         return 1000
-    value.short_description = 'Value in $US'
 
+    @admin.display
     def multiline(self, instance):
         return "Multiline\ntest\nstring"
 
+    @admin.display
     def multiline_html(self, instance):
         return mark_safe("Multiline<br>\nhtml<br>\ncontent")
 
@@ -655,9 +650,9 @@ class ComplexSortedPersonAdmin(admin.ModelAdmin):
     list_display = ('name', 'age', 'is_employee', 'colored_name')
     ordering = ('name',)
 
+    @admin.display(ordering='name')
     def colored_name(self, obj):
         return format_html('<span style="color: #ff00ff;">{}</span>', obj.name)
-    colored_name.admin_order_field = 'name'
 
 
 class PluggableSearchPersonAdmin(admin.ModelAdmin):
@@ -706,20 +701,18 @@ class AdminOrderedModelMethodAdmin(admin.ModelAdmin):
 
 
 class AdminOrderedAdminMethodAdmin(admin.ModelAdmin):
+    @admin.display(ordering='order')
     def some_admin_order(self, obj):
         return obj.order
-    some_admin_order.admin_order_field = 'order'
     ordering = ('order',)
     list_display = ('stuff', 'some_admin_order')
 
 
+@admin.display(ordering='order')
 def admin_ordered_callable(obj):
     return obj.order
 
 
-admin_ordered_callable.admin_order_field = 'order'
-
-
 class AdminOrderedCallableAdmin(admin.ModelAdmin):
     ordering = ('order',)
     list_display = ('stuff', admin_ordered_callable)
@@ -814,6 +807,7 @@ class UnchangeableObjectAdmin(admin.ModelAdmin):
         return [p for p in urlpatterns if p.name and not p.name.endswith("_change")]
 
 
+@admin.display
 def callable_on_unknown(obj):
     return obj.unknown
 
@@ -831,21 +825,27 @@ class MessageTestingAdmin(admin.ModelAdmin):
     actions = ["message_debug", "message_info", "message_success",
                "message_warning", "message_error", "message_extra_tags"]
 
+    @admin.action
     def message_debug(self, request, selected):
         self.message_user(request, "Test debug", level="debug")
 
+    @admin.action
     def message_info(self, request, selected):
         self.message_user(request, "Test info", level="info")
 
+    @admin.action
     def message_success(self, request, selected):
         self.message_user(request, "Test success", level="success")
 
+    @admin.action
     def message_warning(self, request, selected):
         self.message_user(request, "Test warning", level="warning")
 
+    @admin.action
     def message_error(self, request, selected):
         self.message_user(request, "Test error", level="error")
 
+    @admin.action
     def message_extra_tags(self, request, selected):
         self.message_user(request, "Test tags", extra_tags="extra_tag")
 
@@ -1156,9 +1156,9 @@ class ArticleAdmin6(admin.ModelAdmin):
     )
     sortable_by = ('date', callable_year)
 
+    @admin.display(ordering='date')
     def modeladmin_year(self, obj):
         return obj.date.year
-    modeladmin_year.admin_order_field = 'date'
 
 
 class ActorAdmin6(admin.ModelAdmin):

+ 7 - 8
tests/admin_views/models.py

@@ -3,6 +3,7 @@ import os
 import tempfile
 import uuid
 
+from django.contrib import admin
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import (
     GenericForeignKey, GenericRelation,
@@ -45,20 +46,18 @@ class Article(models.Model):
     def __str__(self):
         return self.title
 
+    @admin.display(ordering='date', description='')
     def model_year(self):
         return self.date.year
-    model_year.admin_order_field = 'date'
-    model_year.short_description = ''
 
+    @admin.display(ordering='-date', description='')
     def model_year_reversed(self):
         return self.date.year
-    model_year_reversed.admin_order_field = '-date'
-    model_year_reversed.short_description = ''
 
-    def property_year(self):
+    @property
+    @admin.display(ordering='date')
+    def model_property_year(self):
         return self.date.year
-    property_year.admin_order_field = 'date'
-    model_property_year = property(property_year)
 
     @property
     def model_month(self):
@@ -746,9 +745,9 @@ class AdminOrderedModelMethod(models.Model):
     order = models.IntegerField()
     stuff = models.CharField(max_length=200)
 
+    @admin.display(ordering='order')
     def some_order(self):
         return self.order
-    some_order.admin_order_field = 'order'
 
 
 class AdminOrderedAdminMethod(models.Model):

+ 12 - 0
tests/admin_views/tests.py

@@ -7,6 +7,7 @@ from urllib.parse import parse_qsl, urljoin, urlparse
 
 import pytz
 
+from django.contrib import admin
 from django.contrib.admin import AdminSite, ModelAdmin
 from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.models import ADDITION, DELETION, LogEntry
@@ -751,6 +752,17 @@ class AdminViewBasicTest(AdminViewBasicTestCase):
         response = self.client.get(reverse('admin:admin_views_post_changelist'))
         self.assertContains(response, 'icon-unknown.svg')
 
+    def test_display_decorator_with_boolean_and_empty_value(self):
+        msg = (
+            'The boolean and empty_value arguments to the @display decorator '
+            'are mutually exclusive.'
+        )
+        with self.assertRaisesMessage(ValueError, msg):
+            class BookAdmin(admin.ModelAdmin):
+                @admin.display(boolean=True, empty_value='(Missing)')
+                def is_published(self, obj):
+                    return obj.publish_date is not None
+
     def test_i18n_language_non_english_default(self):
         """
         Check if the JavaScript i18n view returns an empty language catalog

+ 8 - 7
tests/modeladmin/test_actions.py

@@ -27,6 +27,7 @@ class AdminActionsTests(TestCase):
         class BandAdmin(admin.ModelAdmin):
             actions = ['custom_action']
 
+            @admin.action
             def custom_action(modeladmin, request, queryset):
                 pass
 
@@ -60,6 +61,7 @@ class AdminActionsTests(TestCase):
         class AdminBase(admin.ModelAdmin):
             actions = ['custom_action']
 
+            @admin.action
             def custom_action(modeladmin, request, queryset):
                 pass
 
@@ -78,13 +80,14 @@ class AdminActionsTests(TestCase):
         self.assertEqual(action_names, ['delete_selected'])
 
     def test_global_actions_description(self):
+        @admin.action(description='Site-wide admin action 1.')
         def global_action_1(modeladmin, request, queryset):
             pass
 
+        @admin.action
         def global_action_2(modeladmin, request, queryset):
             pass
 
-        global_action_1.short_description = 'Site-wide admin action 1.'
         admin_site = admin.AdminSite()
         admin_site.add_action(global_action_1)
         admin_site.add_action(global_action_2)
@@ -103,30 +106,28 @@ class AdminActionsTests(TestCase):
         )
 
     def test_actions_replace_global_action(self):
+        @admin.action(description='Site-wide admin action 1.')
         def global_action_1(modeladmin, request, queryset):
             pass
 
+        @admin.action(description='Site-wide admin action 2.')
         def global_action_2(modeladmin, request, queryset):
             pass
 
-        global_action_1.short_description = 'Site-wide admin action 1.'
-        global_action_2.short_description = 'Site-wide admin action 2.'
         admin.site.add_action(global_action_1, name='custom_action_1')
         admin.site.add_action(global_action_2, name='custom_action_2')
 
+        @admin.action(description='Local admin action 1.')
         def custom_action_1(modeladmin, request, queryset):
             pass
 
-        custom_action_1.short_description = 'Local admin action 1.'
-
         class BandAdmin(admin.ModelAdmin):
             actions = [custom_action_1, 'custom_action_2']
 
+            @admin.action(description='Local admin action 2.')
             def custom_action_2(self, request, queryset):
                 pass
 
-            custom_action_2.short_description = 'Local admin action 2.'
-
         ma = BandAdmin(Band, admin.site)
         self.assertEqual(ma.check(), [])
         self.assertEqual(

+ 9 - 2
tests/modeladmin/test_checks.py

@@ -1,4 +1,5 @@
 from django import forms
+from django.contrib import admin
 from django.contrib.admin import BooleanFieldListFilter, SimpleListFilter
 from django.contrib.admin.options import VERTICAL, ModelAdmin, TabularInline
 from django.contrib.admin.sites import AdminSite
@@ -499,10 +500,12 @@ class ListDisplayTests(CheckTestCase):
         )
 
     def test_valid_case(self):
+        @admin.display
         def a_callable(obj):
             pass
 
         class TestModelAdmin(ModelAdmin):
+            @admin.display
             def a_method(self, obj):
                 pass
             list_display = ('name', 'decade_published_in', 'a_method', a_callable)
@@ -563,10 +566,12 @@ class ListDisplayLinksCheckTests(CheckTestCase):
         )
 
     def test_valid_case(self):
+        @admin.display
         def a_callable(obj):
             pass
 
         class TestModelAdmin(ModelAdmin):
+            @admin.display
             def a_method(self, obj):
                 pass
             list_display = ('name', 'decade_published_in', 'a_method', a_callable)
@@ -1417,11 +1422,10 @@ class AutocompleteFieldsTests(CheckTestCase):
 class ActionsCheckTests(CheckTestCase):
 
     def test_custom_permissions_require_matching_has_method(self):
+        @admin.action(permissions=['custom'])
         def custom_permission_action(modeladmin, request, queryset):
             pass
 
-        custom_permission_action.allowed_permissions = ('custom',)
-
         class BandAdmin(ModelAdmin):
             actions = (custom_permission_action,)
 
@@ -1433,6 +1437,7 @@ class ActionsCheckTests(CheckTestCase):
         )
 
     def test_actions_not_unique(self):
+        @admin.action
         def action(modeladmin, request, queryset):
             pass
 
@@ -1447,9 +1452,11 @@ class ActionsCheckTests(CheckTestCase):
         )
 
     def test_actions_unique(self):
+        @admin.action
         def action1(modeladmin, request, queryset):
             pass
 
+        @admin.action
         def action2(modeladmin, request, queryset):
             pass