From 0599a56d817cc0a728b85ec936ea1c1d62cf19c9 Mon Sep 17 00:00:00 2001
From: Abdelrahman <abdelrahmanhamada65@gmail.com>
Date: Mon, 5 Feb 2024 02:58:25 +0200
Subject: [PATCH] Add support for related fields in generic
 IndexView.list_display

---
 CHANGELOG.txt                                 |  1 +
 CONTRIBUTORS.md                               |  1 +
 docs/releases/6.1.md                          |  1 +
 wagtail/admin/ui/tables/__init__.py           |  5 ++-
 wagtail/admin/views/generic/models.py         | 34 +++++++++++++++-
 wagtail/snippets/tests/test_viewset.py        | 39 +++++++++++++++++++
 .../0023_snippetchoosermodel_full_featured.py |  1 +
 wagtail/test/testapp/models.py                |  6 ++-
 wagtail/test/testapp/wagtail_hooks.py         | 13 +++++++
 9 files changed, 97 insertions(+), 4 deletions(-)

diff --git a/CHANGELOG.txt b/CHANGELOG.txt
index f4692041f6..5b66531a7b 100644
--- a/CHANGELOG.txt
+++ b/CHANGELOG.txt
@@ -21,6 +21,7 @@ Changelog
  * Add simple admin keyboard shortcuts overview dialog, available in the help sub-menu (Karthik Ayangar, Rohit Sharma)
  * Add ability to bulk toggle permissions in the user group editing view, including shift+click for multiple selections (LB (Ben) Johnston, Kalob Taulien)
  * Update the minimum version of `djangorestframework` to 3.15.1 (Sage Abdullah)
+ * Add support for related fields in generic `IndexView.list_display` (Abdelrahman Hamada)
  * Fix: Fix typo in `__str__` for MySQL search index (Jake Howard)
  * Fix: Ensure that unit tests correctly check for migrations in all core Wagtail apps (Matt Westcott)
  * Fix: Correctly handle `date` objects on `human_readable_date` template tag (Jhonatan Lopes)
diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md
index dd2545aab9..7685ac8cd6 100644
--- a/CONTRIBUTORS.md
+++ b/CONTRIBUTORS.md
@@ -808,6 +808,7 @@
 * Mark Niehues
 * Georgios Roumeliotis
 * David Buxton
+* Abdelrahman Hamada
 
 ## Translators
 
diff --git a/docs/releases/6.1.md b/docs/releases/6.1.md
index d451756c3a..888bc366c9 100644
--- a/docs/releases/6.1.md
+++ b/docs/releases/6.1.md
@@ -31,6 +31,7 @@ depth: 1
  * Add simple admin keyboard shortcuts overview dialog, available in the help sub-menu (Karthik Ayangar, Rohit Sharma)
  * Add ability to bulk toggle permissions in the user group editing view, including shift+click for multiple selections (LB (Ben) Johnston, Kalob Taulien)
  * Update the minimum version of `djangorestframework` to 3.15.1 (Sage Abdullah)
+ * Add support for related fields in generic `IndexView.list_display` (Abdelrahman Hamada)
 
 
 ### Bug fixes
diff --git a/wagtail/admin/ui/tables/__init__.py b/wagtail/admin/ui/tables/__init__.py
index 36a1018dfd..474f9d7c1b 100644
--- a/wagtail/admin/ui/tables/__init__.py
+++ b/wagtail/admin/ui/tables/__init__.py
@@ -149,7 +149,10 @@ class Column(BaseColumn):
         if callable(self.accessor):
             return self.accessor(instance)
         else:
-            return multigetattr(instance, self.accessor)
+            try:
+                return multigetattr(instance, self.accessor)
+            except AttributeError:
+                return None
 
     def get_cell_context_data(self, instance, parent_context):
         context = super().get_cell_context_data(instance, parent_context)
diff --git a/wagtail/admin/views/generic/models.py b/wagtail/admin/views/generic/models.py
index 04fea7a002..5f646e24ee 100644
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -9,6 +9,7 @@ from django.core.exceptions import (
 )
 from django.db import models, transaction
 from django.db.models import Q
+from django.db.models.constants import LOOKUP_SEP
 from django.db.models.functions import Cast
 from django.http import Http404, HttpResponseRedirect
 from django.shortcuts import get_object_or_404, redirect
@@ -256,7 +257,32 @@ class IndexView(
         )
 
     def _get_custom_column(self, field_name, column_class=Column, **kwargs):
-        label, attr = label_for_field(field_name, self.model, return_attr=True)
+        lookups = (
+            [field_name]
+            if hasattr(self.model, field_name)
+            else field_name.split(LOOKUP_SEP)
+        )
+        *relations, field = lookups
+        model_class = self.model
+
+        # Iterate over the relation list to try to get the last model
+        # where the field exists
+        foreign_field_name = ""
+        for model in relations:
+            foreign_field = model_class._meta.get_field(model)
+            foreign_field_name = foreign_field.verbose_name
+            model_class = foreign_field.related_model
+
+        label, attr = label_for_field(field, model_class, return_attr=True)
+
+        # For some languages, it may be more appropriate to put the field label
+        # before the related model name
+        if foreign_field_name:
+            label = _("%(related_model_name)s %(field_label)s") % {
+                "related_model_name": foreign_field_name,
+                "field_label": label,
+            }
+
         sort_key = getattr(attr, "admin_order_field", None)
 
         # attr is None if the field is an actual database field,
@@ -264,8 +290,12 @@ class IndexView(
         if attr is None:
             sort_key = field_name
 
+        accessor = field_name
+        # Build the dotted relation if needed, for use in multigetattr
+        if relations:
+            accessor = ".".join(lookups)
         return column_class(
-            field_name,
+            accessor,
             label=capfirst(label),
             sort_key=sort_key,
             **kwargs,
diff --git a/wagtail/snippets/tests/test_viewset.py b/wagtail/snippets/tests/test_viewset.py
index 9cce127184..8e99b6e7a9 100644
--- a/wagtail/snippets/tests/test_viewset.py
+++ b/wagtail/snippets/tests/test_viewset.py
@@ -42,6 +42,7 @@ from wagtail.test.testapp.models import (
 )
 from wagtail.test.utils import WagtailTestUtils
 from wagtail.test.utils.template_tests import AdminTemplateTestUtils
+from wagtail.utils.timestamps import render_timestamp
 
 
 class TestIncorrectRegistration(SimpleTestCase):
@@ -775,6 +776,44 @@ class TestListViewWithCustomColumns(BaseSnippetViewSetTests):
         )
 
 
+class TestRelatedFieldListDisplay(BaseSnippetViewSetTests):
+    model = SnippetChooserModel
+
+    def setUp(self):
+        super().setUp()
+        url = "https://example.com/free_examples"
+        self.advert = Advert.objects.create(url=url, text="Free Examples")
+        self.ffs = FullFeaturedSnippet.objects.create(text="royale with cheese")
+
+    def test_empty_foreignkey(self):
+        self.no_ffs_chooser = self.model.objects.create(advert=self.advert)
+        response = self.client.get(self.get_url("list"))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Chosen snippet text")
+        self.assertContains(response, "<td></td>", html=True)
+
+    def test_single_level_relation(self):
+        self.scm = self.model.objects.create(advert=self.advert, full_featured=self.ffs)
+        response = self.client.get(self.get_url("list"))
+        self.assertEqual(response.status_code, 200)
+        soup = self.get_soup(response.content)
+        headers = [
+            header.get_text(strip=True)
+            for header in soup.select("#listing-results table th")
+        ]
+        self.assertIn("Chosen snippet text", headers)
+        self.assertContains(response, "<td>royale with cheese</td>", html=True)
+
+    def test_multi_level_relation(self):
+        self.scm = self.model.objects.create(advert=self.advert, full_featured=self.ffs)
+        dummy_revision = self.ffs.save_revision()
+        timestamp = render_timestamp(dummy_revision.created_at)
+        response = self.client.get(self.get_url("list"))
+        self.assertEqual(response.status_code, 200)
+        self.assertContains(response, "Latest revision created at")
+        self.assertContains(response, f"<td>{timestamp}</td>", html=True)
+
+
 class TestListExport(BaseSnippetViewSetTests):
     model = FullFeaturedSnippet
 
diff --git a/wagtail/test/testapp/migrations/0023_snippetchoosermodel_full_featured.py b/wagtail/test/testapp/migrations/0023_snippetchoosermodel_full_featured.py
index ab607bc229..5b1da9d2e8 100644
--- a/wagtail/test/testapp/migrations/0023_snippetchoosermodel_full_featured.py
+++ b/wagtail/test/testapp/migrations/0023_snippetchoosermodel_full_featured.py
@@ -19,6 +19,7 @@ class Migration(migrations.Migration):
                 null=True,
                 on_delete=django.db.models.deletion.CASCADE,
                 to="tests.fullfeaturedsnippet",
+                verbose_name="Chosen snippet",
             ),
         ),
     ]
diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py
index fa7cd0facd..cb5a6b5dcf 100644
--- a/wagtail/test/testapp/models.py
+++ b/wagtail/test/testapp/models.py
@@ -1434,7 +1434,11 @@ class EventPageChooserModel(models.Model):
 class SnippetChooserModel(models.Model):
     advert = models.ForeignKey(Advert, help_text="help text", on_delete=models.CASCADE)
     full_featured = models.ForeignKey(
-        FullFeaturedSnippet, on_delete=models.CASCADE, null=True, blank=True
+        FullFeaturedSnippet,
+        on_delete=models.CASCADE,
+        null=True,
+        blank=True,
+        verbose_name="Chosen snippet",
     )
 
     panels = [
diff --git a/wagtail/test/testapp/wagtail_hooks.py b/wagtail/test/testapp/wagtail_hooks.py
index c9435e5746..789e0708b0 100644
--- a/wagtail/test/testapp/wagtail_hooks.py
+++ b/wagtail/test/testapp/wagtail_hooks.py
@@ -31,6 +31,7 @@ from wagtail.test.testapp.models import (
     ModeratedModel,
     RevisableChildModel,
     RevisableModel,
+    SnippetChooserModel,
     VariousOnDeleteModel,
 )
 from wagtail.test.testapp.views import (
@@ -385,12 +386,24 @@ class VariousOnDeleteModelViewSet(SnippetViewSet):
     inspect_view_enabled = True
 
 
+class SnippetChooserModelViewSet(SnippetViewSet):
+    model = SnippetChooserModel
+
+    list_display = [
+        "__str__",
+        "full_featured__text",
+        "full_featured__latest_revision__created_at",
+    ]
+    exclude_form_fields = []
+
+
 register_snippet(FullFeaturedSnippet, viewset=FullFeaturedSnippetViewSet)
 register_snippet(DraftStateModel, viewset=DraftStateModelViewSet)
 # Works with both classes and instances
 register_snippet(ModeratedModelViewSet())
 register_snippet(RevisableViewSetGroup)
 register_snippet(VariousOnDeleteModelViewSet)
+register_snippet(SnippetChooserModelViewSet)
 
 
 @hooks.register("register_bulk_action")