From 0bebe532e870b5aecb7850b39d3f3ce28197e642 Mon Sep 17 00:00:00 2001 From: Sage Abdullah Date: Fri, 30 Jun 2023 17:42:57 +0100 Subject: [PATCH] Add docs and tests for snippets inspect view --- docs/reference/viewsets.md | 5 + docs/topics/snippets/customising.md | 13 ++ wagtail/snippets/tests/test_viewset.py | 227 ++++++++++++++++++++++++- wagtail/snippets/views/snippets.py | 12 +- wagtail/test/testapp/models.py | 5 +- wagtail/test/testapp/wagtail_hooks.py | 7 + 6 files changed, 264 insertions(+), 5 deletions(-) diff --git a/docs/reference/viewsets.md b/docs/reference/viewsets.md index 73cb301828..7204fdb777 100644 --- a/docs/reference/viewsets.md +++ b/docs/reference/viewsets.md @@ -96,6 +96,9 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit .. autoattribute:: chooser_per_page .. autoattribute:: export_filename .. autoattribute:: ordering + .. autoattribute:: inspect_view_enabled + .. autoattribute:: inspect_view_fields + .. autoattribute:: inspect_view_fields_exclude .. autoattribute:: admin_url_namespace .. autoattribute:: base_url_path .. autoattribute:: chooser_admin_url_namespace @@ -106,6 +109,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit .. autoattribute:: delete_view_class .. autoattribute:: usage_view_class .. autoattribute:: history_view_class + .. autoattribute:: inspect_view_class .. autoattribute:: revisions_view_class .. autoattribute:: revisions_revert_view_class .. autoattribute:: revisions_compare_view_class @@ -137,6 +141,7 @@ Viewsets are Wagtail's mechanism for defining a group of related admin views wit .. automethod:: get_edit_template .. automethod:: get_delete_template .. automethod:: get_history_template + .. automethod:: get_inspect_template .. automethod:: get_admin_url_namespace .. automethod:: get_admin_base_path .. automethod:: get_chooser_admin_url_namespace diff --git a/docs/topics/snippets/customising.md b/docs/topics/snippets/customising.md index 7d326fba22..888c4d6639 100644 --- a/docs/topics/snippets/customising.md +++ b/docs/topics/snippets/customising.md @@ -53,6 +53,7 @@ class MemberViewSet(SnippetViewSet): icon = "user" list_display = ["name", "shirt_size", "get_shirt_size_display", UpdatedAtColumn()] list_per_page = 50 + inspect_view_enabled = True admin_url_namespace = "member_views" base_url_path = "internal/member" filterset_class = MemberFilterSet @@ -99,6 +100,18 @@ You can add the ability to export the listing view to a spreadsheet by setting t The ability to export the listing view was added. ``` +## Inspect view + +```{versionadded} 5.1 +The ability to enable inspect view was added. +``` + +The inspect view is disabled by default, as it's not often useful for most models. However, if you need a view that enables users to view more detailed information about an instance without the option to edit it, you can enable the inspect view by setting {attr}`~wagtail.snippets.views.snippets.SnippetViewSet.inspect_view_enabled` on your `SnippetViewSet` class. + +When inspect view is enabled, an 'Inspect' button will automatically appear for each row on the listing view, which takes you to a view that shows a list of field values for that particular snippet. + +By default, all 'concrete' fields (where the field value is stored as a column in the database table for your model) will be shown. You can customise what values are displayed by specifying the {attr}`~wagtail.snippets.views.snippets.SnippetViewSet.inspect_view_fields` or the {attr}`~wagtail.snippets.views.snippets.SnippetViewSet.inspect_view_fields_exclude` attributes to your `SnippetViewSet` class. + ## Templates For all views that are used for a snippet model, Wagtail looks for templates in the following directories within your project or app, before resorting to the defaults: diff --git a/wagtail/snippets/tests/test_viewset.py b/wagtail/snippets/tests/test_viewset.py index 528f3cdf9b..f658f0dc06 100644 --- a/wagtail/snippets/tests/test_viewset.py +++ b/wagtail/snippets/tests/test_viewset.py @@ -8,8 +8,9 @@ from django.contrib.auth import get_permission_codename from django.contrib.auth.models import Permission from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ImproperlyConfigured +from django.template.defaultfilters import date from django.test import TestCase, TransactionTestCase -from django.urls import reverse +from django.urls import NoReverseMatch, resolve, reverse from django.utils.timezone import now from openpyxl import load_workbook @@ -20,6 +21,10 @@ from wagtail.admin.staticfiles import versioned_static from wagtail.admin.views.mixins import ExcelDateFormatter from wagtail.blocks.field_block import FieldBlockAdapter from wagtail.coreutils import get_dummy_request +from wagtail.documents import get_document_model +from wagtail.documents.tests.utils import get_test_document_file +from wagtail.images import get_image_model +from wagtail.images.tests.utils import get_test_image_file from wagtail.models import Locale, Workflow, WorkflowContentType from wagtail.snippets.blocks import SnippetChooserBlock from wagtail.snippets.models import register_snippet @@ -33,6 +38,7 @@ from wagtail.test.testapp.models import ( RevisableChildModel, RevisableModel, SnippetChooserModel, + VariousOnDeleteModel, ) from wagtail.test.utils import WagtailTestUtils @@ -1073,3 +1079,222 @@ class TestCustomFormClass(BaseSnippetViewSetTests): edit_view = self.client.get(self.get_url("edit", args=(quote(obj.pk),))) self.assertContains(edit_view, 'Text
Perkedel
", + html=True, + ) + self.assertContains( + response, + "
Country code
Indonesia
", + html=True, + ) + self.assertContains( + response, + f"
Some date
{date(self.object.some_date)}
", + html=True, + ) + self.assertNotContains( + response, + "
Some attribute
some value
", + html=True, + ) + self.assertContains( + response, + self.get_url("edit", args=(quote(self.object.pk),)), + ) + self.assertContains( + response, + self.get_url("delete", args=(quote(self.object.pk),)), + ) + + def test_disabled(self): + self.model = Advert + object = self.model.objects.create(text="ad") + with self.assertRaises(NoReverseMatch): + self.get_url("inspect", args=(quote(object.pk),)) + + def test_only_add_permission(self): + self.model = FullFeaturedSnippet + + self.user.is_superuser = False + self.user.user_permissions.add( + Permission.objects.get( + content_type__app_label="wagtailadmin", codename="access_admin" + ), + Permission.objects.get( + content_type__app_label=self.model._meta.app_label, + codename=get_permission_codename("add", self.model._meta), + ), + ) + self.user.save() + + url = self.get_url("inspect", args=(quote(self.object.pk),)) + response = self.client.get(url) + + self.assertContains( + response, + "
Text
Perkedel
", + html=True, + ) + self.assertContains( + response, + "
Country code
Indonesia
", + html=True, + ) + self.assertContains( + response, + f"
Some date
{date(self.object.some_date)}
", + html=True, + ) + self.assertNotContains( + response, + self.get_url("edit", args=(quote(self.object.pk),)), + ) + self.assertNotContains( + response, + self.get_url("delete", args=(quote(self.object.pk),)), + ) + + def test_custom_fields(self): + self.model = FullFeaturedSnippet + url = self.get_url("inspect", args=(quote(self.object.pk),)) + view_func = resolve(url).func + + adverts = [Advert.objects.create(text=f"advertisement {i}") for i in range(3)] + queryset = Advert.objects.filter(pk=adverts[0].pk) + + mock_manager = mock.patch.object( + self.model, "adverts", Advert.objects, create=True + ) + + mock_queryset = mock.patch.object( + self.model, "some_queryset", queryset, create=True + ) + + mock_fields = mock.patch.dict( + view_func.view_initkwargs, + { + "fields": [ + "country_code", # Field with choices (thus get_FOO_display method) + "some_date", # DateField + "some_attribute", # Model attribute + "adverts", # Manager + "some_queryset", # QuerySet + ] + }, + ) + + # We need to mock the view's init kwargs instead of the viewset's + # attributes, because the viewset's attributes are only used when the + # view is instantiated, and the view is instantiated once at startup. + with mock_manager, mock_queryset, mock_fields: + response = self.client.get(url) + + self.assertNotContains( + response, + "
Text
Perkedel
", + html=True, + ) + self.assertContains( + response, + "
Country code
Indonesia
", + html=True, + ) + self.assertContains( + response, + f"
Some date
{date(self.object.some_date)}
", + html=True, + ) + self.assertContains( + response, + "
Some attribute
some value
", + html=True, + ) + self.assertContains( + response, + """ +
Adverts
+
advertisement 0, advertisement 1, advertisement 2
+ """, + html=True, + ) + self.assertContains( + response, + "
Some queryset
advertisement 0
", + html=True, + ) + + def test_exclude_fields(self): + self.model = FullFeaturedSnippet + url = self.get_url("inspect", args=(quote(self.object.pk),)) + view_func = resolve(url).func + + # We need to mock the view's init kwargs instead of the viewset's + # attributes, because the viewset's attributes are only used when the + # view is instantiated, and the view is instantiated once at startup. + with mock.patch.dict( + view_func.view_initkwargs, + {"fields_exclude": ["some_date"]}, + ): + response = self.client.get(url) + + self.assertContains( + response, + "
Text
Perkedel
", + html=True, + ) + self.assertContains( + response, + "
Country code
Indonesia
", + html=True, + ) + self.assertNotContains( + response, + f"
Some date
{date(self.object.some_date)}
", + html=True, + ) + self.assertNotContains( + response, + "
Some attribute
some value
", + html=True, + ) + + def test_image_and_document_fields(self): + self.model = VariousOnDeleteModel + image = get_image_model().objects.create( + title="Test image", + file=get_test_image_file(), + ) + document = get_document_model().objects.create( + title="Test document", file=get_test_document_file() + ) + object = self.model.objects.create( + protected_image=image, protected_document=document + ) + response = self.client.get(self.get_url("inspect", args=(quote(object.pk),))) + + self.assertContains( + response, + f"
Protected image
{image.get_rendition('max-400x400').img_tag()}
", + html=True, + ) + self.assertContains(response, "
Protected document
", html=True) + self.assertContains(response, f'') + self.assertContains(response, "Test document") + self.assertContains(response, "TXT") + self.assertContains(response, f"{document.file.size}\xa0bytes") diff --git a/wagtail/snippets/views/snippets.py b/wagtail/snippets/views/snippets.py index d684266896..9933602ab5 100644 --- a/wagtail/snippets/views/snippets.py +++ b/wagtail/snippets/views/snippets.py @@ -701,7 +701,17 @@ class SnippetViewSet(ModelViewSet): #: Whether to enable the inspect view. Defaults to ``False``. inspect_view_enabled = False - #: The fields to display in the inspect view. + #: The model fields or attributes to display in the inspect view. + #: + #: If the field has a corresponding :meth:`~django.db.models.Model.get_FOO_display` + #: method on the model, the method's return value will be used instead. + #: + #: If you have ``wagtail.images`` installed, and the field's value is an instance of + #: ``wagtail.images.models.AbstractImage``, a thumbnail of that image will be rendered. + #: + #: If you have ``wagtail.documents`` installed, and the field's value is an instance of + #: ``wagtail.docs.models.AbstractDocument``, a link to that document will be rendered, + #: along with the document title, file extension and size. inspect_view_fields = [] #: The fields to exclude from the inspect view. diff --git a/wagtail/test/testapp/models.py b/wagtail/test/testapp/models.py index 65b881dc6b..f2df3daa78 100644 --- a/wagtail/test/testapp/models.py +++ b/wagtail/test/testapp/models.py @@ -1117,6 +1117,8 @@ class FullFeaturedSnippet( ) some_date = models.DateField(auto_now=True) + some_attribute = "some value" + search_fields = [ index.SearchField("text"), index.FilterField("text"), @@ -1225,9 +1227,6 @@ class VariousOnDeleteModel(models.Model): rich_text = RichTextField(blank=True) -register_snippet(VariousOnDeleteModel) - - class StandardIndex(Page): """Index for the site""" diff --git a/wagtail/test/testapp/wagtail_hooks.py b/wagtail/test/testapp/wagtail_hooks.py index d59faf0008..76f115ae9b 100644 --- a/wagtail/test/testapp/wagtail_hooks.py +++ b/wagtail/test/testapp/wagtail_hooks.py @@ -28,6 +28,7 @@ from wagtail.test.testapp.models import ( ModeratedModel, RevisableChildModel, RevisableModel, + VariousOnDeleteModel, ) from .forms import FavouriteColourForm @@ -342,7 +343,13 @@ class ModeratedModelViewSet(SnippetViewSet): } +class VariousOnDeleteModelViewSet(SnippetViewSet): + model = VariousOnDeleteModel + inspect_view_enabled = True + + register_snippet(FullFeaturedSnippet, viewset=FullFeaturedSnippetViewSet) register_snippet(DraftStateModel, viewset=DraftStateModelViewSet) register_snippet(ModeratedModelViewSet) register_snippet(RevisableViewSetGroup) +register_snippet(VariousOnDeleteModelViewSet)