Fixed #10743 -- Allowed lookups for related fields in ModelAdmin.list_display.

Co-authored-by: Alex Garcia <me@alexoteiza.com>
Co-authored-by: Natalia <124304+nessita@users.noreply.github.com>
Co-authored-by: Nina Menezes <https://github.com/nmenezes0>
This commit is contained in:
Tom Carrick 2023-04-04 15:11:11 +01:00 committed by Natalia
parent 3580b47ed3
commit 4ade8386eb
13 changed files with 186 additions and 46 deletions

View File

@ -44,6 +44,7 @@ answer newbie questions, and generally made Django that much better:
Albert Wang <https://github.com/albertyw/> Albert Wang <https://github.com/albertyw/>
Alcides Fonseca Alcides Fonseca
Aldian Fazrihady <mobile@aldian.net> Aldian Fazrihady <mobile@aldian.net>
Alejandro García Ruiz de Oteiza <https://github.com/AlexOteiza>
Aleksandra Sendecka <asendecka@hauru.eu> Aleksandra Sendecka <asendecka@hauru.eu>
Aleksi Häkli <aleksi.hakli@iki.fi> Aleksi Häkli <aleksi.hakli@iki.fi>
Alex Dutton <django@alexdutton.co.uk> Alex Dutton <django@alexdutton.co.uk>
@ -760,6 +761,7 @@ answer newbie questions, and generally made Django that much better:
Nicolas Noé <nicolas@niconoe.eu> Nicolas Noé <nicolas@niconoe.eu>
Nikita Marchant <nikita.marchant@gmail.com> Nikita Marchant <nikita.marchant@gmail.com>
Nikita Sobolev <mail@sobolevn.me> Nikita Sobolev <mail@sobolevn.me>
Nina Menezes <https://github.com/nmenezes0>
Niran Babalola <niran@niran.org> Niran Babalola <niran@niran.org>
Nis Jørgensen <nis@superlativ.dk> Nis Jørgensen <nis@superlativ.dk>
Nowell Strite <https://nowell.strite.org/> Nowell Strite <https://nowell.strite.org/>

View File

@ -915,17 +915,15 @@ class ModelAdminChecks(BaseModelAdminChecks):
try: try:
field = getattr(obj.model, item) field = getattr(obj.model, item)
except AttributeError: except AttributeError:
try:
field = get_fields_from_path(obj.model, item)[-1]
except (FieldDoesNotExist, NotRelationField):
return [ return [
checks.Error( checks.Error(
"The value of '%s' refers to '%s', which is not a " f"The value of '{label}' refers to '{item}', which is not "
"callable, an attribute of '%s', or an attribute or " f"a callable or attribute of '{obj.__class__.__name__}', "
"method on '%s'." "or an attribute, method, or field on "
% ( f"'{obj.model._meta.label}'.",
label,
item,
obj.__class__.__name__,
obj.model._meta.label,
),
obj=obj.__class__, obj=obj.__class__,
id="admin.E108", id="admin.E108",
) )

View File

@ -289,8 +289,8 @@ def lookup_field(name, obj, model_admin=None):
try: try:
f = _get_non_gfk_field(opts, name) f = _get_non_gfk_field(opts, name)
except (FieldDoesNotExist, FieldIsAForeignKeyColumnName): except (FieldDoesNotExist, FieldIsAForeignKeyColumnName):
# For non-field values, the value is either a method, property or # For non-regular field values, the value is either a method,
# returned via a callable. # property, related field, or returned via a callable.
if callable(name): if callable(name):
attr = name attr = name
value = attr(obj) value = attr(obj)
@ -298,10 +298,17 @@ def lookup_field(name, obj, model_admin=None):
attr = getattr(model_admin, name) attr = getattr(model_admin, name)
value = attr(obj) value = attr(obj)
else: else:
attr = getattr(obj, name) sentinel = object()
attr = getattr(obj, name, sentinel)
if callable(attr): if callable(attr):
value = attr() value = attr()
else: 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 value = attr
if hasattr(model_admin, "model") and hasattr(model_admin.model, name): if hasattr(model_admin, "model") and hasattr(model_admin.model, name):
attr = getattr(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, 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 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 object's attribute, as well as a model field, including across related
return the resolved attribute (which could be a callable). This will be objects. If return_attr is True, also return the resolved attribute
None if (and only if) the name refers to a field. (which could be a callable). This will be None if (and only if) the name
refers to a field.
""" """
attr = None attr = None
try: try:
@ -371,14 +379,14 @@ def label_for_field(name, model, model_admin=None, return_attr=False, form=None)
elif form and name in form.fields: elif form and name in form.fields:
attr = form.fields[name] attr = form.fields[name]
else: else:
message = "Unable to lookup '%s' on %s" % ( try:
name, attr = get_fields_from_path(model, name)[-1]
model._meta.object_name, except (FieldDoesNotExist, NotRelationField):
) message = f"Unable to lookup '{name}' on {model._meta.object_name}"
if model_admin: if model_admin:
message += " or %s" % model_admin.__class__.__name__ message += f" or {model_admin.__class__.__name__}"
if form: if form:
message += " or %s" % form.__class__.__name__ message += f" or {form.__class__.__name__}"
raise AttributeError(message) raise AttributeError(message)
if hasattr(attr, "short_description"): if hasattr(attr, "short_description"):

View File

@ -30,6 +30,7 @@ from django.core.exceptions import (
) )
from django.core.paginator import InvalidPage from django.core.paginator import InvalidPage
from django.db.models import F, Field, ManyToOneRel, OrderBy 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.db.models.expressions import Combinable
from django.urls import reverse from django.urls import reverse
from django.utils.deprecation import RemovedInDjango60Warning from django.utils.deprecation import RemovedInDjango60Warning
@ -356,9 +357,9 @@ class ChangeList:
""" """
Return the proper model field name corresponding to the given 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 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 proper model field, possibly across relations, or the name of a method
callable with the 'admin_order_field' attribute. Return None if no (on the admin or model) or a callable with the 'admin_order_field'
proper model field name can be matched. attribute. Return None if no proper model field name can be matched.
""" """
try: try:
field = self.lookup_opts.get_field(field_name) field = self.lookup_opts.get_field(field_name)
@ -371,7 +372,12 @@ class ChangeList:
elif hasattr(self.model_admin, field_name): elif hasattr(self.model_admin, field_name):
attr = getattr(self.model_admin, field_name) attr = getattr(self.model_admin, field_name)
else: else:
try:
attr = getattr(self.model, field_name) 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"): if isinstance(attr, property) and hasattr(attr, "fget"):
attr = attr.fget attr = attr.fget
return getattr(attr, "admin_order_field", None) return getattr(attr, "admin_order_field", None)

View File

@ -726,9 +726,9 @@ with the admin site:
* **admin.E106**: The value of ``<InlineModelAdmin class>.model`` must be a * **admin.E106**: The value of ``<InlineModelAdmin class>.model`` must be a
``Model``. ``Model``.
* **admin.E107**: The value of ``list_display`` must be a list or tuple. * **admin.E107**: The value of ``list_display`` must be a list or tuple.
* **admin.E108**: The value of ``list_display[n]`` refers to ``<label>``, * **admin.E108**: The value of ``list_display[n]`` refers to ``<label>``, which
which is not a callable, an attribute of ``<ModelAdmin class>``, or an is not a callable or attribute of ``<ModelAdmin class>``, or an attribute,
attribute or method on ``<model>``. method, or field on ``<model>``.
* **admin.E109**: The value of ``list_display[n]`` must not be a many-to-many * **admin.E109**: The value of ``list_display[n]`` must not be a many-to-many
field or a reverse foreign key. field or a reverse foreign key.
* **admin.E110**: The value of ``list_display_links`` must be a list, a tuple, * **admin.E110**: The value of ``list_display_links`` must be a list, a tuple,

View File

@ -315,9 +315,9 @@ subclass::
For more complex layout needs, see the :attr:`~ModelAdmin.fieldsets` option. For more complex layout needs, see the :attr:`~ModelAdmin.fieldsets` option.
The ``fields`` option accepts the same types of values as The ``fields`` option accepts the same types of values as
:attr:`~ModelAdmin.list_display`, except that callables aren't accepted. :attr:`~ModelAdmin.list_display`, except that callables and ``__`` lookups
Names of model and model admin methods will only be used if they're listed for related fields aren't accepted. Names of model and model admin methods
in :attr:`~ModelAdmin.readonly_fields`. will only be used if they're listed in :attr:`~ModelAdmin.readonly_fields`.
To display multiple fields on the same line, wrap those fields in their own To display multiple fields on the same line, wrap those fields in their own
tuple. In this example, the ``url`` and ``title`` fields will display on the tuple. In this example, the ``url`` and ``title`` fields will display on the
@ -565,7 +565,7 @@ 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``. All There are five types of values that can be used in ``list_display``. All
but the simplest may use the :func:`~django.contrib.admin.display` but the simplest may use the :func:`~django.contrib.admin.display`
decorator, which is used to customize how the field is presented: decorator, which is used to customize how the field is presented:
@ -574,6 +574,11 @@ subclass::
class PersonAdmin(admin.ModelAdmin): class PersonAdmin(admin.ModelAdmin):
list_display = ["first_name", "last_name"] list_display = ["first_name", "last_name"]
* The name of a related field, using the ``__`` notation. For example::
class PersonAdmin(admin.ModelAdmin):
list_display = ["city__name"]
* 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") @admin.display(description="Name")
@ -614,6 +619,11 @@ subclass::
class PersonAdmin(admin.ModelAdmin): class PersonAdmin(admin.ModelAdmin):
list_display = ["name", "decade_born_in"] list_display = ["name", "decade_born_in"]
.. versionchanged:: 5.1
Support for using ``__`` lookups was added, when targeting related
fields.
A few special cases to note about ``list_display``: A few special cases to note about ``list_display``:
* If the field is a ``ForeignKey``, Django will display the * If the field is a ``ForeignKey``, Django will display the
@ -831,7 +841,7 @@ subclass::
* Django will try to interpret every element of ``list_display`` in this * Django will try to interpret every element of ``list_display`` in this
order: order:
* A field of the model. * A field of the model or from a related field.
* A callable. * A callable.
* A string representing a ``ModelAdmin`` attribute. * A string representing a ``ModelAdmin`` attribute.
* A string representing a model attribute. * A string representing a model attribute.

View File

@ -32,7 +32,8 @@ Minor features
:mod:`django.contrib.admin` :mod:`django.contrib.admin`
~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~
* ... * :attr:`.ModelAdmin.list_display` now supports using ``__`` lookups to list
fields from related models.
:mod:`django.contrib.admindocs` :mod:`django.contrib.admindocs`
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

View File

@ -53,6 +53,10 @@ class ChildAdmin(admin.ModelAdmin):
return super().get_queryset(request).select_related("parent") return super().get_queryset(request).select_related("parent")
class GrandChildAdmin(admin.ModelAdmin):
list_display = ["name", "parent__name", "parent__parent__name"]
class CustomPaginationAdmin(ChildAdmin): class CustomPaginationAdmin(ChildAdmin):
paginator = CustomPaginator paginator = CustomPaginator

View File

@ -19,6 +19,11 @@ class Child(models.Model):
age = models.IntegerField(null=True, blank=True) age = models.IntegerField(null=True, blank=True)
class GrandChild(models.Model):
parent = models.ForeignKey(Child, models.SET_NULL, editable=False, null=True)
name = models.CharField(max_length=30, blank=True)
class Genre(models.Model): class Genre(models.Model):
name = models.CharField(max_length=20) name = models.CharField(max_length=20)

View File

@ -42,6 +42,7 @@ from .admin import (
EmptyValueChildAdmin, EmptyValueChildAdmin,
EventAdmin, EventAdmin,
FilteredChildAdmin, FilteredChildAdmin,
GrandChildAdmin,
GroupAdmin, GroupAdmin,
InvitationAdmin, InvitationAdmin,
NoListDisplayLinksParentAdmin, NoListDisplayLinksParentAdmin,
@ -61,6 +62,7 @@ from .models import (
CustomIdUser, CustomIdUser,
Event, Event,
Genre, Genre,
GrandChild,
Group, Group,
Invitation, Invitation,
Membership, Membership,
@ -1634,6 +1636,62 @@ class ChangeListTests(TestCase):
response, f'0 results (<a href="{href}">1 total</a>)' response, f'0 results (<a href="{href}">1 total</a>)'
) )
def test_list_display_related_field(self):
parent = Parent.objects.create(name="I am your father")
child = Child.objects.create(name="I am your child", parent=parent)
GrandChild.objects.create(name="I am your grandchild", parent=child)
request = self._mocked_authenticated_request("/grandchild/", self.superuser)
m = GrandChildAdmin(GrandChild, custom_site)
response = m.changelist_view(request)
self.assertContains(response, parent.name)
self.assertContains(response, child.name)
def test_list_display_related_field_null(self):
GrandChild.objects.create(name="I am parentless", parent=None)
request = self._mocked_authenticated_request("/grandchild/", self.superuser)
m = GrandChildAdmin(GrandChild, custom_site)
response = m.changelist_view(request)
self.assertContains(response, '<td class="field-parent__name">-</td>')
self.assertContains(response, '<td class="field-parent__parent__name">-</td>')
def test_list_display_related_field_ordering(self):
parent_a = Parent.objects.create(name="Alice")
parent_z = Parent.objects.create(name="Zara")
Child.objects.create(name="Alice's child", parent=parent_a)
Child.objects.create(name="Zara's child", parent=parent_z)
class ChildAdmin(admin.ModelAdmin):
list_display = ["name", "parent__name"]
list_per_page = 1
m = ChildAdmin(Child, custom_site)
# Order ascending.
request = self._mocked_authenticated_request("/grandchild/?o=1", self.superuser)
response = m.changelist_view(request)
self.assertContains(response, parent_a.name)
self.assertNotContains(response, parent_z.name)
# Order descending.
request = self._mocked_authenticated_request(
"/grandchild/?o=-1", self.superuser
)
response = m.changelist_view(request)
self.assertNotContains(response, parent_a.name)
self.assertContains(response, parent_z.name)
def test_list_display_related_field_ordering_fields(self):
class ChildAdmin(admin.ModelAdmin):
list_display = ["name", "parent__name"]
ordering = ["parent__name"]
m = ChildAdmin(Child, custom_site)
request = self._mocked_authenticated_request("/", self.superuser)
cl = m.get_changelist_instance(request)
self.assertEqual(cl.get_ordering_field_columns(), {2: "asc"})
class GetAdminLogTests(TestCase): class GetAdminLogTests(TestCase):
def test_custom_user_pk_not_named_id(self): def test_custom_user_pk_not_named_id(self):

View File

@ -1009,3 +1009,26 @@ class SystemChecksTestCase(SimpleTestCase):
self.assertEqual(errors, []) self.assertEqual(errors, [])
finally: finally:
Book._meta.apps.ready = True Book._meta.apps.ready = True
def test_related_field_list_display(self):
class SongAdmin(admin.ModelAdmin):
list_display = ["pk", "original_release", "album__title"]
errors = SongAdmin(Song, AdminSite()).check()
self.assertEqual(errors, [])
def test_related_field_list_display_wrong_field(self):
class SongAdmin(admin.ModelAdmin):
list_display = ["pk", "original_release", "album__hello"]
errors = SongAdmin(Song, AdminSite()).check()
expected = [
checks.Error(
"The value of 'list_display[2]' refers to 'album__hello', which is not "
"a callable or attribute of 'SongAdmin', or an attribute, method, or "
"field on 'admin_checks.Song'.",
obj=SongAdmin,
id="admin.E108",
)
]
self.assertEqual(errors, expected)

View File

@ -137,6 +137,7 @@ class UtilsTests(SimpleTestCase):
(simple_function, SIMPLE_FUNCTION), (simple_function, SIMPLE_FUNCTION),
("test_from_model", article.test_from_model()), ("test_from_model", article.test_from_model()),
("non_field", INSTANCE_ATTRIBUTE), ("non_field", INSTANCE_ATTRIBUTE),
("site__domain", SITE_NAME),
) )
mock_admin = MockModelAdmin() mock_admin = MockModelAdmin()
@ -294,6 +295,17 @@ class UtilsTests(SimpleTestCase):
self.assertEqual(label_for_field(lambda x: "nothing", Article), "--") self.assertEqual(label_for_field(lambda x: "nothing", Article), "--")
self.assertEqual(label_for_field("site_id", Article), "Site id") self.assertEqual(label_for_field("site_id", Article), "Site id")
# The correct name and attr are returned when `__` is in the field name.
self.assertEqual(label_for_field("site__domain", Article), "Site domain")
self.assertEqual(
label_for_field("site__domain", Article, return_attr=True),
("Site domain", Site._meta.get_field("domain")),
)
def test_label_for_field_failed_lookup(self):
msg = "Unable to lookup 'site__unknown' on Article"
with self.assertRaisesMessage(AttributeError, msg):
label_for_field("site__unknown", Article)
class MockModelAdmin: class MockModelAdmin:
@admin.display(description="not Really the Model") @admin.display(description="not Really the Model")

View File

@ -69,7 +69,7 @@ class RawIdCheckTests(CheckTestCase):
def test_missing_field(self): def test_missing_field(self):
class TestModelAdmin(ModelAdmin): class TestModelAdmin(ModelAdmin):
raw_id_fields = ("non_existent_field",) raw_id_fields = ["non_existent_field"]
self.assertIsInvalid( self.assertIsInvalid(
TestModelAdmin, TestModelAdmin,
@ -602,8 +602,21 @@ class ListDisplayTests(CheckTestCase):
TestModelAdmin, TestModelAdmin,
ValidationTestModel, ValidationTestModel,
"The value of 'list_display[0]' refers to 'non_existent_field', " "The value of 'list_display[0]' refers to 'non_existent_field', "
"which is not a callable, an attribute of 'TestModelAdmin', " "which is not a callable or attribute of 'TestModelAdmin', "
"or an attribute or method on 'modeladmin.ValidationTestModel'.", "or an attribute, method, or field on 'modeladmin.ValidationTestModel'.",
"admin.E108",
)
def test_missing_related_field(self):
class TestModelAdmin(ModelAdmin):
list_display = ("band__non_existent_field",)
self.assertIsInvalid(
TestModelAdmin,
ValidationTestModel,
"The value of 'list_display[0]' refers to 'band__non_existent_field', "
"which is not a callable or attribute of 'TestModelAdmin', "
"or an attribute, method, or field on 'modeladmin.ValidationTestModel'.",
"admin.E108", "admin.E108",
) )