From 4ade8386ebfeb7a781dc2b62542c1cf5f8b9ddaf Mon Sep 17 00:00:00 2001 From: Tom Carrick Date: Tue, 4 Apr 2023 15:11:11 +0100 Subject: [PATCH] Fixed #10743 -- Allowed lookups for related fields in ModelAdmin.list_display. Co-authored-by: Alex Garcia Co-authored-by: Natalia <124304+nessita@users.noreply.github.com> Co-authored-by: Nina Menezes --- AUTHORS | 2 ++ django/contrib/admin/checks.py | 28 +++++++-------- django/contrib/admin/utils.py | 38 ++++++++++++-------- django/contrib/admin/views/main.py | 14 +++++--- docs/ref/checks.txt | 6 ++-- docs/ref/contrib/admin/index.txt | 20 ++++++++--- docs/releases/5.1.txt | 3 +- tests/admin_changelist/admin.py | 4 +++ tests/admin_changelist/models.py | 5 +++ tests/admin_changelist/tests.py | 58 ++++++++++++++++++++++++++++++ tests/admin_checks/tests.py | 23 ++++++++++++ tests/admin_utils/tests.py | 12 +++++++ tests/modeladmin/test_checks.py | 19 ++++++++-- 13 files changed, 186 insertions(+), 46 deletions(-) diff --git a/AUTHORS b/AUTHORS index 8c903ff6c5..26cfe71138 100644 --- a/AUTHORS +++ b/AUTHORS @@ -44,6 +44,7 @@ answer newbie questions, and generally made Django that much better: Albert Wang Alcides Fonseca Aldian Fazrihady + Alejandro García Ruiz de Oteiza Aleksandra Sendecka Aleksi Häkli Alex Dutton @@ -760,6 +761,7 @@ answer newbie questions, and generally made Django that much better: Nicolas Noé Nikita Marchant Nikita Sobolev + Nina Menezes Niran Babalola Nis Jørgensen Nowell Strite diff --git a/django/contrib/admin/checks.py b/django/contrib/admin/checks.py index aa43718cd6..c1a17af076 100644 --- a/django/contrib/admin/checks.py +++ b/django/contrib/admin/checks.py @@ -915,21 +915,19 @@ class ModelAdminChecks(BaseModelAdminChecks): try: field = getattr(obj.model, item) except AttributeError: - return [ - checks.Error( - "The value of '%s' refers to '%s', which is not a " - "callable, an attribute of '%s', or an attribute or " - "method on '%s'." - % ( - label, - item, - obj.__class__.__name__, - obj.model._meta.label, - ), - obj=obj.__class__, - id="admin.E108", - ) - ] + try: + field = get_fields_from_path(obj.model, item)[-1] + except (FieldDoesNotExist, NotRelationField): + return [ + checks.Error( + f"The value of '{label}' refers to '{item}', which is not " + f"a callable or attribute of '{obj.__class__.__name__}', " + "or an attribute, method, or field on " + f"'{obj.model._meta.label}'.", + obj=obj.__class__, + id="admin.E108", + ) + ] if ( getattr(field, "is_relation", False) and (field.many_to_many or field.one_to_many) diff --git a/django/contrib/admin/utils.py b/django/contrib/admin/utils.py index 0bcf99ae85..c8e722bcc8 100644 --- a/django/contrib/admin/utils.py +++ b/django/contrib/admin/utils.py @@ -289,8 +289,8 @@ def lookup_field(name, obj, model_admin=None): try: f = _get_non_gfk_field(opts, name) except (FieldDoesNotExist, FieldIsAForeignKeyColumnName): - # For non-field values, the value is either a method, property or - # returned via a callable. + # For non-regular field values, the value is either a method, + # property, related field, or returned via a callable. if callable(name): attr = name value = attr(obj) @@ -298,10 +298,17 @@ def lookup_field(name, obj, model_admin=None): attr = getattr(model_admin, name) value = attr(obj) else: - attr = getattr(obj, name) + sentinel = object() + attr = getattr(obj, name, sentinel) if callable(attr): value = attr() else: + if attr is sentinel: + attr = obj + for part in name.split(LOOKUP_SEP): + attr = getattr(attr, part, sentinel) + if attr is sentinel: + return None, None, None value = attr if hasattr(model_admin, "model") and hasattr(model_admin.model, name): attr = getattr(model_admin.model, name) @@ -345,9 +352,10 @@ def label_for_field(name, model, model_admin=None, return_attr=False, form=None) """ Return a sensible label for a field name. The name can be a callable, property (but not created with @property decorator), or the name of an - object's attribute, as well as a model field. If return_attr is True, also - return the resolved attribute (which could be a callable). This will be - None if (and only if) the name refers to a field. + object's attribute, as well as a model field, including across related + objects. If return_attr is True, also return the resolved attribute + (which could be a callable). This will be None if (and only if) the name + refers to a field. """ attr = None try: @@ -371,15 +379,15 @@ def label_for_field(name, model, model_admin=None, return_attr=False, form=None) elif form and name in form.fields: attr = form.fields[name] else: - message = "Unable to lookup '%s' on %s" % ( - name, - model._meta.object_name, - ) - if model_admin: - message += " or %s" % model_admin.__class__.__name__ - if form: - message += " or %s" % form.__class__.__name__ - raise AttributeError(message) + try: + attr = get_fields_from_path(model, name)[-1] + except (FieldDoesNotExist, NotRelationField): + message = f"Unable to lookup '{name}' on {model._meta.object_name}" + if model_admin: + message += f" or {model_admin.__class__.__name__}" + if form: + message += f" or {form.__class__.__name__}" + raise AttributeError(message) if hasattr(attr, "short_description"): label = attr.short_description diff --git a/django/contrib/admin/views/main.py b/django/contrib/admin/views/main.py index 44001f00f9..d8fff50d18 100644 --- a/django/contrib/admin/views/main.py +++ b/django/contrib/admin/views/main.py @@ -30,6 +30,7 @@ from django.core.exceptions import ( ) from django.core.paginator import InvalidPage from django.db.models import F, Field, ManyToOneRel, OrderBy +from django.db.models.constants import LOOKUP_SEP from django.db.models.expressions import Combinable from django.urls import reverse from django.utils.deprecation import RemovedInDjango60Warning @@ -356,9 +357,9 @@ class ChangeList: """ Return the proper model field name corresponding to the given field_name to use for ordering. field_name may either be the name of a - proper model field or the name of a method (on the admin or model) or a - callable with the 'admin_order_field' attribute. Return None if no - proper model field name can be matched. + proper model field, possibly across relations, or the name of a method + (on the admin or model) or a callable with the 'admin_order_field' + attribute. Return None if no proper model field name can be matched. """ try: field = self.lookup_opts.get_field(field_name) @@ -371,7 +372,12 @@ class ChangeList: elif hasattr(self.model_admin, field_name): attr = getattr(self.model_admin, field_name) else: - attr = getattr(self.model, field_name) + try: + attr = getattr(self.model, field_name) + except AttributeError: + if LOOKUP_SEP in field_name: + return field_name + raise if isinstance(attr, property) and hasattr(attr, "fget"): attr = attr.fget return getattr(attr, "admin_order_field", None) diff --git a/docs/ref/checks.txt b/docs/ref/checks.txt index f0eeaca268..cf0ab32efa 100644 --- a/docs/ref/checks.txt +++ b/docs/ref/checks.txt @@ -726,9 +726,9 @@ with the admin site: * **admin.E106**: The value of ``.model`` must be a ``Model``. * **admin.E107**: The value of ``list_display`` must be a list or tuple. -* **admin.E108**: The value of ``list_display[n]`` refers to ``