소스 검색

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

Refs #25134, #32099.
Nick Pope 4 년 전
부모
커밋
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 (
 from django.contrib.admin.filters import (
     AllValuesFieldListFilter, BooleanFieldListFilter, ChoicesFieldListFilter,
     AllValuesFieldListFilter, BooleanFieldListFilter, ChoicesFieldListFilter,
     DateFieldListFilter, EmptyFieldListFilter, FieldListFilter, ListFilter,
     DateFieldListFilter, EmptyFieldListFilter, FieldListFilter, ListFilter,
@@ -11,10 +11,10 @@ from django.contrib.admin.sites import AdminSite, site
 from django.utils.module_loading import autodiscover_modules
 from django.utils.module_loading import autodiscover_modules
 
 
 __all__ = [
 __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",
     "AllValuesFieldListFilter", "EmptyFieldListFilter",
     "RelatedOnlyFieldListFilter", "autodiscover",
     "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 import messages
 from django.contrib.admin import helpers
 from django.contrib.admin import helpers
+from django.contrib.admin.decorators import action
 from django.contrib.admin.utils import model_ngettext
 from django.contrib.admin.utils import model_ngettext
 from django.core.exceptions import PermissionDenied
 from django.core.exceptions import PermissionDenied
 from django.template.response import TemplateResponse
 from django.template.response import TemplateResponse
 from django.utils.translation import gettext as _, gettext_lazy
 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):
 def delete_selected(modeladmin, request, queryset):
     """
     """
     Default action which deletes the selected objects.
     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/%s/delete_selected_confirmation.html" % app_label,
         "admin/delete_selected_confirmation.html"
         "admin/delete_selected_confirmation.html"
     ], context)
     ], 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):
 def register(*models, site=None):
     """
     """
     Register the given model(s) classes and wrapped ModelAdmin class with
     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 (
 from django.contrib.admin.checks import (
     BaseModelAdminChecks, InlineModelAdminChecks, ModelAdminChecks,
     BaseModelAdminChecks, InlineModelAdminChecks, ModelAdminChecks,
 )
 )
+from django.contrib.admin.decorators import display
 from django.contrib.admin.exceptions import DisallowedModelAdminToField
 from django.contrib.admin.exceptions import DisallowedModelAdminToField
 from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
 from django.contrib.admin.templatetags.admin_urls import add_preserved_filters
 from django.contrib.admin.utils import (
 from django.contrib.admin.utils import (
@@ -848,12 +849,12 @@ class ModelAdmin(BaseModelAdmin):
             action_flag=DELETION,
             action_flag=DELETION,
         )
         )
 
 
+    @display(description=mark_safe('<input type="checkbox" id="action-toggle">'))
     def action_checkbox(self, obj):
     def action_checkbox(self, obj):
         """
         """
         A list_display column containing a checkbox widget.
         A list_display column containing a checkbox widget.
         """
         """
         return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk))
         return helpers.checkbox.render(helpers.ACTION_CHECKBOX_NAME, str(obj.pk))
-    action_checkbox.short_description = mark_safe('<input type="checkbox" id="action-toggle">')
 
 
     @staticmethod
     @staticmethod
     def _get_action_description(func, name):
     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
 underscores replaced with spaces), and that each line contains the string
 representation of the output.
 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
 .. code-block:: python
     :caption: polls/models.py
     :caption: polls/models.py
 
 
+    from django.contrib import admin
+
     class Question(models.Model):
     class Question(models.Model):
         # ...
         # ...
+        @admin.display(
+            boolean=True,
+            ordering='pub_date',
+            description='Published recently?',
+        )
         def was_published_recently(self):
         def was_published_recently(self):
             now = timezone.now()
             now = timezone.now()
             return now - datetime.timedelta(days=1) <= self.pub_date <= 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`.
 :attr:`~django.contrib.admin.ModelAdmin.list_display`.
 
 
 Edit your :file:`polls/admin.py` file again and add an improvement to the
 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.
 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" --
 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
 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):
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
         queryset.update(status='p')
-    make_published.short_description = "Mark selected stories as published"
 
 
 .. note::
 .. 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`
 Adding actions to the :class:`ModelAdmin`
 -----------------------------------------
 -----------------------------------------
@@ -122,9 +136,9 @@ the action and its registration would look like::
     from django.contrib import admin
     from django.contrib import admin
     from myapp.models import Article
     from myapp.models import Article
 
 
+    @admin.action(description='Mark selected stories as published')
     def make_published(modeladmin, request, queryset):
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
         queryset.update(status='p')
-    make_published.short_description = "Mark selected stories as published"
 
 
     class ArticleAdmin(admin.ModelAdmin):
     class ArticleAdmin(admin.ModelAdmin):
         list_display = ['title', 'status']
         list_display = ['title', 'status']
@@ -171,9 +185,9 @@ You can do it like this::
 
 
         actions = ['make_published']
         actions = ['make_published']
 
 
+        @admin.action(description='Mark selected stories as published')
         def make_published(self, request, queryset):
         def make_published(self, request, queryset):
             queryset.update(status='p')
             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
 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
 ``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
 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):
     def make_published(modeladmin, request, queryset):
         queryset.update(status='p')
         queryset.update(status='p')
-    make_published.allowed_permissions = ('change',)
 
 
 The ``make_published()`` action will only be available to users that pass the
 The ``make_published()`` action will only be available to users that pass the
 :meth:`.ModelAdmin.has_change_permission` check.
 :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`
 - ``'add'``: :meth:`.ModelAdmin.has_add_permission`
 - ``'change'``: :meth:`.ModelAdmin.has_change_permission`
 - ``'change'``: :meth:`.ModelAdmin.has_change_permission`
@@ -395,12 +409,55 @@ For example::
     class ArticleAdmin(admin.ModelAdmin):
     class ArticleAdmin(admin.ModelAdmin):
         actions = ['make_published']
         actions = ['make_published']
 
 
+        @admin.action(permissions=['publish'])
         def make_published(self, request, queryset):
         def make_published(self, request, queryset):
             queryset.update(status='p')
             queryset.update(status='p')
-        make_published.allowed_permissions = ('publish',)
 
 
         def has_publish_permission(self, request):
         def has_publish_permission(self, request):
             """Does the user have the publish permission?"""
             """Does the user have the publish permission?"""
             opts = self.opts
             opts = self.opts
             codename = get_permission_codename('publish', opts)
             codename = get_permission_codename('publish', opts)
             return request.user.has_perm('%s.%s' % (opts.app_label, codename))
             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):
         class AuthorAdmin(admin.ModelAdmin):
             fields = ('name', 'title', 'view_birth_date')
             fields = ('name', 'title', 'view_birth_date')
 
 
+            @admin.display(empty_value='???')
             def view_birth_date(self, obj):
             def view_birth_date(self, obj):
                 return obj.birth_date
                 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
 .. attribute:: ModelAdmin.exclude
 
 
@@ -551,7 +558,9 @@ subclass::
     If you don't set ``list_display``, the admin site will display a single
     If you don't set ``list_display``, the admin site will display a single
     column that displays the ``__str__()`` representation of each object.
     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::
     * The name of a model field. For example::
 
 
@@ -560,9 +569,9 @@ subclass::
 
 
     * A callable that accepts one argument, the model instance. For example::
     * A callable that accepts one argument, the model instance. For example::
 
 
+          @admin.display(description='Name')
           def upper_case_name(obj):
           def upper_case_name(obj):
               return ("%s %s" % (obj.first_name, obj.last_name)).upper()
               return ("%s %s" % (obj.first_name, obj.last_name)).upper()
-          upper_case_name.short_description = 'Name'
 
 
           class PersonAdmin(admin.ModelAdmin):
           class PersonAdmin(admin.ModelAdmin):
               list_display = (upper_case_name,)
               list_display = (upper_case_name,)
@@ -573,9 +582,9 @@ subclass::
           class PersonAdmin(admin.ModelAdmin):
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('upper_case_name',)
               list_display = ('upper_case_name',)
 
 
+              @admin.display(description='Name')
               def upper_case_name(self, obj):
               def upper_case_name(self, obj):
                   return ("%s %s" % (obj.first_name, obj.last_name)).upper()
                   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
     * A string representing a model attribute or method (without any required
       arguments). For example::
       arguments). For example::
@@ -587,9 +596,9 @@ subclass::
               name = models.CharField(max_length=50)
               name = models.CharField(max_length=50)
               birthday = models.DateField()
               birthday = models.DateField()
 
 
+              @admin.display(description='Birth decade')
               def decade_born_in(self):
               def decade_born_in(self):
                   return '%d’s' % (self.birthday.year // 10 * 10)
                   return '%d’s' % (self.birthday.year // 10 * 10)
-              decade_born_in.short_description = 'Birth decade'
 
 
           class PersonAdmin(admin.ModelAdmin):
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'decade_born_in')
               list_display = ('name', 'decade_born_in')
@@ -624,6 +633,7 @@ subclass::
               last_name = models.CharField(max_length=50)
               last_name = models.CharField(max_length=50)
               color_code = models.CharField(max_length=6)
               color_code = models.CharField(max_length=6)
 
 
+              @admin.display
               def colored_name(self):
               def colored_name(self):
                   return format_html(
                   return format_html(
                       '<span style="color: #{};">{} {}</span>',
                       '<span style="color: #{};">{} {}</span>',
@@ -637,7 +647,17 @@ subclass::
 
 
     * As some examples have already demonstrated, when using a callable, a
     * As some examples have already demonstrated, when using a callable, a
       model method, or a ``ModelAdmin`` method, you can customize the column's
       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
     * If the value of a field is ``None``, an empty string, or an iterable
       without elements, Django will display ``-`` (a dash). You can override
       without elements, Django will display ``-`` (a dash). You can override
@@ -657,17 +677,23 @@ subclass::
           class PersonAdmin(admin.ModelAdmin):
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'birth_date_view')
               list_display = ('name', 'birth_date_view')
 
 
+              @admin.display(empty_value='unknown')
               def birth_date_view(self, obj):
               def birth_date_view(self, obj):
                    return obj.birth_date
                    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
     * If the string given is a method of the model, ``ModelAdmin`` or a
       callable that returns ``True``, ``False``, or ``None``, Django will
       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.contrib import admin
           from django.db import models
           from django.db import models
@@ -676,13 +702,21 @@ subclass::
               first_name = models.CharField(max_length=50)
               first_name = models.CharField(max_length=50)
               birthday = models.DateField()
               birthday = models.DateField()
 
 
+              @admin.display(boolean=True)
               def born_in_fifties(self):
               def born_in_fifties(self):
                   return 1950 <= self.birthday.year < 1960
                   return 1950 <= self.birthday.year < 1960
-              born_in_fifties.boolean = True
 
 
           class PersonAdmin(admin.ModelAdmin):
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('name', 'born_in_fifties')
               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
     * The ``__str__()`` method is just as valid in ``list_display`` as any
       other model method, so it's perfectly OK to do this::
       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
       fields can't be used in sorting (because Django does all the sorting
       at the database level).
       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
       The above will tell Django to order by the ``first_name`` field when
       trying to sort by ``colored_first_name`` in the admin.
       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):
           class Blog(models.Model):
               title = models.CharField(max_length=255)
               title = models.CharField(max_length=255)
@@ -738,13 +770,12 @@ subclass::
           class BlogAdmin(admin.ModelAdmin):
           class BlogAdmin(admin.ModelAdmin):
               list_display = ('title', 'author', 'author_first_name')
               list_display = ('title', 'author', 'author_first_name')
 
 
+              @admin.display(ordering='author__first_name')
               def author_first_name(self, obj):
               def author_first_name(self, obj):
                   return obj.author.first_name
                   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 import Value
           from django.db.models.functions import Concat
           from django.db.models.functions import Concat
@@ -753,32 +784,47 @@ subclass::
               first_name = models.CharField(max_length=50)
               first_name = models.CharField(max_length=50)
               last_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):
               def full_name(self):
                   return self.first_name + ' ' + self.last_name
                   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):
           class Person(models.Model):
               first_name = models.CharField(max_length=50)
               first_name = models.CharField(max_length=50)
               last_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
                   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):
           class PersonAdmin(admin.ModelAdmin):
               list_display = ('full_name',)
               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 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>``
       the HTML output, in the form of ``column-<field_name>`` on each ``<th>``
@@ -1239,6 +1285,8 @@ subclass::
         class PersonAdmin(admin.ModelAdmin):
         class PersonAdmin(admin.ModelAdmin):
             readonly_fields = ('address_report',)
             readonly_fields = ('address_report',)
 
 
+            # description functions like a model field's verbose_name
+            @admin.display(description='Address')
             def address_report(self, instance):
             def address_report(self, instance):
                 # assuming get_full_address() returns a list of strings
                 # assuming get_full_address() returns a list of strings
                 # for each line of the address and you want to separate each
                 # 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()),
                     ((line,) for line in instance.get_full_address()),
                 ) or mark_safe("<span class='errors'>I can't determine this address.</span>")
                 ) 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
 .. attribute:: ModelAdmin.save_as
 
 
     Set ``save_as`` to enable a "save as new" feature on admin change forms.
     Set ``save_as`` to enable a "save as new" feature on admin change forms.
@@ -1360,8 +1405,9 @@ subclass::
 .. attribute:: ModelAdmin.sortable_by
 .. attribute:: ModelAdmin.sortable_by
 
 
     By default, the change list page allows sorting by all model fields (and
     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
     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
     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
 object which has an ``app_label`` and ``model_name`` attributes and is usually
 supplied by the admin views for the current model.
 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
 .. currentmodule:: django.contrib.admin.views.decorators
 
 
 The ``staff_member_required`` decorator
 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/
 .. _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
 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 = _('my thing')
             verbose_name_plural = _('my things')
             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
 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.db import models
     from django.utils.translation import gettext_lazy as _
     from django.utils.translation import gettext_lazy as _
 
 
@@ -406,9 +408,9 @@ with the ``short_description`` attribute::
             verbose_name=_('kind'),
             verbose_name=_('kind'),
         )
         )
 
 
+        @admin.display(description=_('Is it a mouse?'))
         def is_mouse(self):
         def is_mouse(self):
             return self.kind.type == MOUSE_TYPE
             return self.kind.type == MOUSE_TYPE
-        is_mouse.short_description = _('Is it a mouse?')
 
 
 Working with lazy translation objects
 Working with lazy translation objects
 -------------------------------------
 -------------------------------------

+ 2 - 1
tests/admin_changelist/admin.py

@@ -19,6 +19,7 @@ class EventAdmin(admin.ModelAdmin):
     date_hierarchy = 'date'
     date_hierarchy = 'date'
     list_display = ['event_date_func']
     list_display = ['event_date_func']
 
 
+    @admin.display
     def event_date_func(self, event):
     def event_date_func(self, event):
         return event.date
         return event.date
 
 
@@ -171,6 +172,6 @@ class EmptyValueChildAdmin(admin.ModelAdmin):
     empty_value_display = '-empty-'
     empty_value_display = '-empty-'
     list_display = ('name', 'age_display', 'age')
     list_display = ('name', 'age_display', 'age')
 
 
+    @admin.display(empty_value='&dagger;')
     def age_display(self, obj):
     def age_display(self, obj):
         return obj.age
         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, [])
         self.assertEqual(errors, [])
 
 
     def test_readonly_on_method(self):
     def test_readonly_on_method(self):
+        @admin.display
         def my_function(obj):
         def my_function(obj):
             pass
             pass
 
 
@@ -705,6 +706,7 @@ class SystemChecksTestCase(SimpleTestCase):
         class SongAdmin(admin.ModelAdmin):
         class SongAdmin(admin.ModelAdmin):
             readonly_fields = ("readonly_method_on_modeladmin",)
             readonly_fields = ("readonly_method_on_modeladmin",)
 
 
+            @admin.display
             def readonly_method_on_modeladmin(self, obj):
             def readonly_method_on_modeladmin(self, obj):
                 pass
                 pass
 
 
@@ -717,6 +719,7 @@ class SystemChecksTestCase(SimpleTestCase):
 
 
             def __getattr__(self, item):
             def __getattr__(self, item):
                 if item == "dynamic_method":
                 if item == "dynamic_method":
+                    @admin.display
                     def method(obj):
                     def method(obj):
                         pass
                         pass
                     return method
                     return method
@@ -777,6 +780,7 @@ class SystemChecksTestCase(SimpleTestCase):
 
 
     def test_extra(self):
     def test_extra(self):
         class SongAdmin(admin.ModelAdmin):
         class SongAdmin(admin.ModelAdmin):
+            @admin.display
             def awesome_song(self, instance):
             def awesome_song(self, instance):
                 if instance.title == "Born to Run":
                 if instance.title == "Born to Run":
                     return "Best Ever!"
                     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.db import models
 from django.utils.translation import gettext_lazy as _
 from django.utils.translation import gettext_lazy as _
 
 
@@ -28,9 +29,9 @@ class Article(models.Model):
     def test_from_model(self):
     def test_from_model(self):
         return "nothing"
         return "nothing"
 
 
+    @admin.display(description='not What you Expect')
     def test_from_model_with_override(self):
     def test_from_model_with_override(self):
         return "nothing"
         return "nothing"
-    test_from_model_with_override.short_description = "not What you Expect"
 
 
 
 
 class ArticleProxy(Article):
 class ArticleProxy(Article):

+ 5 - 6
tests/admin_utils/tests.py

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

+ 7 - 8
tests/admin_views/models.py

@@ -3,6 +3,7 @@ import os
 import tempfile
 import tempfile
 import uuid
 import uuid
 
 
+from django.contrib import admin
 from django.contrib.auth.models import User
 from django.contrib.auth.models import User
 from django.contrib.contenttypes.fields import (
 from django.contrib.contenttypes.fields import (
     GenericForeignKey, GenericRelation,
     GenericForeignKey, GenericRelation,
@@ -45,20 +46,18 @@ class Article(models.Model):
     def __str__(self):
     def __str__(self):
         return self.title
         return self.title
 
 
+    @admin.display(ordering='date', description='')
     def model_year(self):
     def model_year(self):
         return self.date.year
         return self.date.year
-    model_year.admin_order_field = 'date'
-    model_year.short_description = ''
 
 
+    @admin.display(ordering='-date', description='')
     def model_year_reversed(self):
     def model_year_reversed(self):
         return self.date.year
         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
         return self.date.year
-    property_year.admin_order_field = 'date'
-    model_property_year = property(property_year)
 
 
     @property
     @property
     def model_month(self):
     def model_month(self):
@@ -746,9 +745,9 @@ class AdminOrderedModelMethod(models.Model):
     order = models.IntegerField()
     order = models.IntegerField()
     stuff = models.CharField(max_length=200)
     stuff = models.CharField(max_length=200)
 
 
+    @admin.display(ordering='order')
     def some_order(self):
     def some_order(self):
         return self.order
         return self.order
-    some_order.admin_order_field = 'order'
 
 
 
 
 class AdminOrderedAdminMethod(models.Model):
 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
 import pytz
 
 
+from django.contrib import admin
 from django.contrib.admin import AdminSite, ModelAdmin
 from django.contrib.admin import AdminSite, ModelAdmin
 from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
 from django.contrib.admin.models import ADDITION, DELETION, LogEntry
 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'))
         response = self.client.get(reverse('admin:admin_views_post_changelist'))
         self.assertContains(response, 'icon-unknown.svg')
         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):
     def test_i18n_language_non_english_default(self):
         """
         """
         Check if the JavaScript i18n view returns an empty language catalog
         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):
         class BandAdmin(admin.ModelAdmin):
             actions = ['custom_action']
             actions = ['custom_action']
 
 
+            @admin.action
             def custom_action(modeladmin, request, queryset):
             def custom_action(modeladmin, request, queryset):
                 pass
                 pass
 
 
@@ -60,6 +61,7 @@ class AdminActionsTests(TestCase):
         class AdminBase(admin.ModelAdmin):
         class AdminBase(admin.ModelAdmin):
             actions = ['custom_action']
             actions = ['custom_action']
 
 
+            @admin.action
             def custom_action(modeladmin, request, queryset):
             def custom_action(modeladmin, request, queryset):
                 pass
                 pass
 
 
@@ -78,13 +80,14 @@ class AdminActionsTests(TestCase):
         self.assertEqual(action_names, ['delete_selected'])
         self.assertEqual(action_names, ['delete_selected'])
 
 
     def test_global_actions_description(self):
     def test_global_actions_description(self):
+        @admin.action(description='Site-wide admin action 1.')
         def global_action_1(modeladmin, request, queryset):
         def global_action_1(modeladmin, request, queryset):
             pass
             pass
 
 
+        @admin.action
         def global_action_2(modeladmin, request, queryset):
         def global_action_2(modeladmin, request, queryset):
             pass
             pass
 
 
-        global_action_1.short_description = 'Site-wide admin action 1.'
         admin_site = admin.AdminSite()
         admin_site = admin.AdminSite()
         admin_site.add_action(global_action_1)
         admin_site.add_action(global_action_1)
         admin_site.add_action(global_action_2)
         admin_site.add_action(global_action_2)
@@ -103,30 +106,28 @@ class AdminActionsTests(TestCase):
         )
         )
 
 
     def test_actions_replace_global_action(self):
     def test_actions_replace_global_action(self):
+        @admin.action(description='Site-wide admin action 1.')
         def global_action_1(modeladmin, request, queryset):
         def global_action_1(modeladmin, request, queryset):
             pass
             pass
 
 
+        @admin.action(description='Site-wide admin action 2.')
         def global_action_2(modeladmin, request, queryset):
         def global_action_2(modeladmin, request, queryset):
             pass
             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_1, name='custom_action_1')
         admin.site.add_action(global_action_2, name='custom_action_2')
         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):
         def custom_action_1(modeladmin, request, queryset):
             pass
             pass
 
 
-        custom_action_1.short_description = 'Local admin action 1.'
-
         class BandAdmin(admin.ModelAdmin):
         class BandAdmin(admin.ModelAdmin):
             actions = [custom_action_1, 'custom_action_2']
             actions = [custom_action_1, 'custom_action_2']
 
 
+            @admin.action(description='Local admin action 2.')
             def custom_action_2(self, request, queryset):
             def custom_action_2(self, request, queryset):
                 pass
                 pass
 
 
-            custom_action_2.short_description = 'Local admin action 2.'
-
         ma = BandAdmin(Band, admin.site)
         ma = BandAdmin(Band, admin.site)
         self.assertEqual(ma.check(), [])
         self.assertEqual(ma.check(), [])
         self.assertEqual(
         self.assertEqual(

+ 9 - 2
tests/modeladmin/test_checks.py

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