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, "", 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, "royale with cheese", 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"{timestamp}", 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")