Repository: wagtail/wagtail

Issue:
Using a related field in Snippet/ModelViewSet causes exception when index list requested
### Issue Summary
When referencing a related model field in a SnippetViewSet or ModelViewSet, requesting the index view causes an exception because the column label cannot be determined.

Specifically, it fails within the Django `label_for_field()` function:

```
Traceback (most recent call last):
  File ".../.venv/lib/python3.10/site-packages/django/db/models/options.py", line 673, in get_field
    return self.fields_map[field_name]
KeyError: 'full_featured.text'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../.venv/lib/python3.10/site-packages/django/contrib/admin/utils.py", line 333, in label_for_field
    field = _get_non_gfk_field(model._meta, name)
  File ".../.venv/lib/python3.10/site-packages/django/contrib/admin/utils.py", line 302, in _get_non_gfk_field
    field = opts.get_field(name)
  File ".../.venv/lib/python3.10/site-packages/django/db/models/options.py", line 675, in get_field
    raise FieldDoesNotExist(
django.core.exceptions.FieldDoesNotExist: SnippetChooserModel has no field named 'full_featured.text'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File ".../wagtail/snippets/tests/test_viewset.py", line 748, in test_snippetchoosermodel_related_fields
    response = self.client.get(self.get_url("list"))
  File ".../.venv/lib/python3.10/site-packages/django/test/client.py", line 836, in get
    response = super().get(path, data=data, secure=secure, **extra)
  File ".../.venv/lib/python3.10/site-packages/django/test/client.py", line 424, in get
    return self.generic(
  File ".../.venv/lib/python3.10/site-packages/django/test/client.py", line 541, in generic
    return self.request(**r)
  File ".../.venv/lib/python3.10/site-packages/django/test/client.py", line 810, in request
    self.check_exception(response)
  File ".../.venv/lib/python3.10/site-packages/django/test/client.py", line 663, in check_exception
    raise exc_value
  File ".../.venv/lib/python3.10/site-packages/django/core/handlers/exception.py", line 56, in inner
    response = get_response(request)
  File ".../.venv/lib/python3.10/site-packages/django/core/handlers/base.py", line 197, in _get_response
    response = wrapped_callback(request, *callback_args, **callback_kwargs)
  File ".../.venv/lib/python3.10/site-packages/django/views/decorators/cache.py", line 62, in _wrapped_view_func
    response = view_func(request, *args, **kwargs)
  File ".../wagtail/admin/urls/__init__.py", line 180, in wrapper
    return view_func(request, *args, **kwargs)
  File ".../wagtail/admin/auth.py", line 167, in decorated_view
    response = view_func(request, *args, **kwargs)
  File ".../.venv/lib/python3.10/site-packages/django/views/generic/base.py", line 78, in view
    self.setup(request, *args, **kwargs)
  File ".../wagtail/admin/views/generic/models.py", line 111, in setup
    self.columns = self.get_columns()
  File ".../wagtail/snippets/views/snippets.py", line 180, in get_columns
    *super().get_columns(),
  File ".../wagtail/admin/views/generic/models.py", line 336, in get_columns
    column = self._get_custom_column(field)
  File ".../wagtail/admin/views/generic/models.py", line 309, in _get_custom_column
    label, attr = label_for_field(field_name, self.model, return_attr=True)
  File ".../.venv/lib/python3.10/site-packages/django/contrib/admin/utils.py", line 361, in label_for_field
    raise AttributeError(message)
AttributeError: Unable to lookup 'full_featured.text' on SnippetChooserModel
```

### Steps to Reproduce

1. Clone the wagtail branch at https://github.com/dkirkham/wagtail/commit/e942cae55fe7e8324eca942b29638c2af9551f48 within a working Wagtail development environment
2. Run the test: `python runtests.py wagtail.snippets.tests.test_viewset.TestRelatedFieldColumns`
3. Note the crash backtrace in the test report.

### Background
I've found myself wanting to refer to fields within related models within ModelViewSet index lists. This is because the `__str__` methods in those referenced models often include other contextual information which is useful in other circumstances, but too much for the index list (which often has that context in adjacent columns). I'd also like to avoid creating extra methods in models to access related model fields.

I (perhaps naïvely) tried using `list_display = ["field__related_field"]` a format which works in `search_fields` and `ordering`, but this fails. Digging into the code, `list_display` ultimately uses `multigetattr()` as the data accessor which reproduces template semantics using dot-separated fields: `list_display = ["field.related_field"]`. Neither format however works to automatically obtain a column label.

It is possible to use the `Column()` class to directly provide a label: `list_display = [Column("field.related_field", label="Related Field")]`

-   I have confirmed that this issue can be reproduced as described on a fresh Wagtail project: (no)
-   I have confirmed that this issue can be demonstrated in a Wagtail test case: (yes)

Is it a bug? The fact that there is code to allow access to the related field data, but the code fails trying to work out the column name would suggest so. The generality of the `multigetattr()` data accessor might be difficult to reproduce when finding the label, so the workaround using `Column()` may be an acceptable and more dependable solution. If so, it might be useful to both detect the failure to find a label and raise this as a warning or configuration error, rather than failing when the index list is requested; and to document the preferred solution.

### Technical details

-   Python version: 3.10, but likely not relevant.
-   Django version: 4.0.10 and 4.2.8, probably others.
-   Wagtail version: 5.2 and wagtail#main
-   Browser version: irrelevant

### Working on this

I've started a [branch](https://github.com/dkirkham/wagtail/tree/fix-index-view-get-related-field-label) with the test demonstrating the failure. I haven't yet started looking to see if an automatic method of determining the label for a related field is possible.

<!--
  Do you have thoughts on skills needed?
  Are you keen to work on this yourself once the issue has been accepted?
  Please let us know here.
-->

Anyone can contribute to this. View our [contributing guidelines](https://docs.wagtail.org/en/latest/contributing/index.html), add a comment to the issue once you’re ready to start.



Discussion:
I cloned the repo and can confirm that the test fails. I am going to try to take a deep dive into the issue. 
I don't know if this is a correct conclusion, but I think what happens is when `label_for_field` get called, it is given two arguments, the field name and the model. What happens during your test case is `label_for_field` receives `full_featured.text` in field name, and `SnippetChooserModel` for the model parameter. What causes the error is that there is no field called `full_featured.text` in `SnippetChooserModel`, which is correct. It will only work in way when the model of `full_featured` is passed as the model parameter, since `label_for_field` can only access the models fields only. Feel free to correct me, I may be to totally wrong.
This fixes it for me, just needs tests and proper namings. This could be considered more an enhancement than a bug fix. Will do tests and open a pull request at most tomorrow.
```python
    def _get_custom_column(self, field_name, column_class=Column, **kwargs):
        field_name_list = field_name.split(".")
        
        field_name = field_name_list[0]
        model = self.model
        
        if len(field_name_list ) > 1:
            field_name = field_name_list[0]
            foreign_field_name = field_name_list[1]
        
            foreign_relation = getattr(self.model, field_name)

            field_name = foreign_field_name
            model = foreign_relation.field.related_model

        label, attr = label_for_field(field_name, model, return_attr=True)

        sort_key = getattr(attr, "admin_order_field", None)

        # attr is None if the field is an actual database field,
        # so it's possible to sort by it
        if attr is None:
            sort_key = field_name

        return column_class(
            field_name,
            label=capfirst(label),
            sort_key=sort_key,
            **kwargs,
        )

Resolving patch:
diff --git a/wagtail/admin/ui/tables/__init__.py b/wagtail/admin/ui/tables/__init__.py
--- a/wagtail/admin/ui/tables/__init__.py
+++ b/wagtail/admin/ui/tables/__init__.py
@@ -149,7 +149,10 @@ def get_value(self, instance):
         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
--- a/wagtail/admin/views/generic/models.py
+++ b/wagtail/admin/views/generic/models.py
@@ -9,6 +9,7 @@
 )
 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 @@ def get_buttons(column, instance, *args, **kwargs):
         )
 
     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 @@ def _get_custom_column(self, field_name, column_class=Column, **kwargs):
         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,


Test patch:
diff --git a/wagtail/snippets/tests/test_viewset.py b/wagtail/snippets/tests/test_viewset.py
--- a/wagtail/snippets/tests/test_viewset.py
+++ b/wagtail/snippets/tests/test_viewset.py
@@ -42,6 +42,7 @@
 )
 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 @@ def test_boolean_column(self):
         )
 
 
+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
--- 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
--- 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
--- a/wagtail/test/testapp/wagtail_hooks.py
+++ b/wagtail/test/testapp/wagtail_hooks.py
@@ -31,6 +31,7 @@
     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")