From 920448539631b52dcee53bd32a880abbc9de18bd Mon Sep 17 00:00:00 2001 From: Nick Pope Date: Wed, 13 Jan 2021 16:19:22 +0000 Subject: [PATCH] Fixed #16117 -- Added decorators for admin action and display functions. Refs #25134, #32099. --- django/contrib/admin/__init__.py | 10 +- django/contrib/admin/actions.py | 9 +- django/contrib/admin/decorators.py | 73 ++++++++++ django/contrib/admin/options.py | 3 +- docs/intro/tutorial07.txt | 16 ++- docs/ref/contrib/admin/actions.txt | 87 ++++++++++-- docs/ref/contrib/admin/index.txt | 212 ++++++++++++++++++++--------- docs/releases/3.2.txt | 22 +++ docs/topics/i18n/translation.txt | 10 +- tests/admin_changelist/admin.py | 3 +- tests/admin_checks/tests.py | 4 + tests/admin_utils/models.py | 3 +- tests/admin_utils/tests.py | 11 +- tests/admin_views/admin.py | 54 ++++---- tests/admin_views/models.py | 15 +- tests/admin_views/tests.py | 12 ++ tests/modeladmin/test_actions.py | 15 +- tests/modeladmin/test_checks.py | 11 +- 18 files changed, 422 insertions(+), 148 deletions(-) diff --git a/django/contrib/admin/__init__.py b/django/contrib/admin/__init__.py index 0bcee1a578..975cf053aa 100644 --- a/django/contrib/admin/__init__.py +++ b/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", ] diff --git a/django/contrib/admin/actions.py b/django/contrib/admin/actions.py index 1e1c3bd384..665d83c7f7 100644 --- a/django/contrib/admin/actions.py +++ b/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") diff --git a/django/contrib/admin/decorators.py b/django/contrib/admin/decorators.py index 1c43c9505c..4de99580ad 100644 --- a/django/contrib/admin/decorators.py +++ b/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 diff --git a/django/contrib/admin/options.py b/django/contrib/admin/options.py index 5214a266e3..6b0982eab8 100644 --- a/django/contrib/admin/options.py +++ b/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('')) 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('') @staticmethod def _get_action_description(func, name): diff --git a/docs/intro/tutorial07.txt b/docs/intro/tutorial07.txt index 9bcc2b40a3..545e40baa3 100644 --- a/docs/intro/tutorial07.txt +++ b/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 diff --git a/docs/ref/contrib/admin/actions.txt b/docs/ref/contrib/admin/actions.txt index e75aa86afa..650eda9b4f 100644 --- a/docs/ref/contrib/admin/actions.txt +++ b/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. diff --git a/docs/ref/contrib/admin/index.txt b/docs/ref/contrib/admin/index.txt index 040b2ada85..aaf5134395 100644 --- a/docs/ref/contrib/admin/index.txt +++ b/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( '{} {}', @@ -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. + 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:: - For example:: + from django.contrib import admin + from django.db import models + from django.utils.html import format_html - 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) - class Person(models.Model): - first_name = models.CharField(max_length=50) - color_code = models.CharField(max_length=6) + @admin.display(ordering='first_name') + def colored_first_name(self): + return format_html( + '{}', + self.color_code, + self.first_name, + ) - def colored_first_name(self): - return format_html( - '{}', - self.color_code, - self.first_name, - ) - - colored_first_name.admin_order_field = '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 ` may be used in - ``admin_order_field``. For example:: + :doc:`Query 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 - For example:: + 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. + + * 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-`` on each ```` @@ -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("I can't determine this address.") - # 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 diff --git a/docs/releases/3.2.txt b/docs/releases/3.2.txt index 57ab1baf34..66607916e0 100644 --- a/docs/releases/3.2.txt +++ b/docs/releases/3.2.txt @@ -141,6 +141,28 @@ Django `. .. _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 -------------- diff --git a/docs/topics/i18n/translation.txt b/docs/topics/i18n/translation.txt index 7a7bf85ebc..1819359725 100644 --- a/docs/topics/i18n/translation.txt +++ b/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 ------------------------------------- diff --git a/tests/admin_changelist/admin.py b/tests/admin_changelist/admin.py index 890919557e..4f23296eca 100644 --- a/tests/admin_changelist/admin.py +++ b/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='†') def age_display(self, obj): return obj.age - age_display.empty_value_display = '†' diff --git a/tests/admin_checks/tests.py b/tests/admin_checks/tests.py index 5744dc1b39..139b6dec59 100644 --- a/tests/admin_checks/tests.py +++ b/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!" diff --git a/tests/admin_utils/models.py b/tests/admin_utils/models.py index fda1380b23..e57c3926b5 100644 --- a/tests/admin_utils/models.py +++ b/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): diff --git a/tests/admin_utils/tests.py b/tests/admin_utils/tests.py index ce9f94dbb9..a74449bdc0 100644 --- a/tests/admin_utils/tests.py +++ b/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), diff --git a/tests/admin_views/admin.py b/tests/admin_views/admin.py index 1140f03496..925da71982 100644 --- a/tests/admin_views/admin.py +++ b/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
\nhtml
\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('{}', 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): diff --git a/tests/admin_views/models.py b/tests/admin_views/models.py index 5224f5f91a..f449ad792b 100644 --- a/tests/admin_views/models.py +++ b/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): diff --git a/tests/admin_views/tests.py b/tests/admin_views/tests.py index 297760f807..0b2415cdb8 100644 --- a/tests/admin_views/tests.py +++ b/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 diff --git a/tests/modeladmin/test_actions.py b/tests/modeladmin/test_actions.py index f7de725ffc..b61641c0c9 100644 --- a/tests/modeladmin/test_actions.py +++ b/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( diff --git a/tests/modeladmin/test_checks.py b/tests/modeladmin/test_checks.py index 308f4a19eb..d81cc3dd32 100644 --- a/tests/modeladmin/test_checks.py +++ b/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