From e2729f4b2bc704d7bccb2db19cf6191dce570dca Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Fri, 20 Sep 2024 12:12:16 +0300 Subject: [PATCH] Replace SnippetObjectType with SnippetInterface Fixes https://github.com/torchbox/wagtail-grapple/issues/386 API clients could use a field on snippet objects to determine the type of snippet they are looking at. Therefore, we change the snippet type to an interface, similar to the page interface, so it can expose a new field called `snippetType`. --- docs/general-usage/graphql-types.rst | 27 --------------- docs/general-usage/interfaces.rst | 31 ++++++++++++++++- docs/getting-started/settings.rst | 17 ++++++++-- grapple/actions.py | 3 +- grapple/settings.py | 1 + grapple/types/interfaces.py | 15 +++++++++ grapple/types/snippets.py | 50 ++++++---------------------- grapple/types/streamfield.py | 28 +++++++--------- tests/settings_custom_interfaces.py | 1 + tests/test_grapple.py | 27 ++++++++++++++- tests/test_interfaces.py | 23 +++++++++++-- tests/testapp/interfaces.py | 9 ++++- tox.ini | 2 +- 13 files changed, 140 insertions(+), 94 deletions(-) diff --git a/docs/general-usage/graphql-types.rst b/docs/general-usage/graphql-types.rst index ff441b6e..156397b0 100644 --- a/docs/general-usage/graphql-types.rst +++ b/docs/general-usage/graphql-types.rst @@ -102,33 +102,6 @@ The following fields are returned: fileHash: String - -SnippetObjectType -^^^^^^^^^^^^^^^^^ - -You won't see much of ``SnippetObjectType`` as it's only a Union type that -groups all your Snippet models together. You can query all the available snippets -under the ``snippets`` field under the root Query, The query is similar to -an interface but ``SnippetObjectType`` doesn't provide any fields itself. - -When snippets are attached to Pages you interact with your generated type itself -as opposed to an interface or base type. - -An example of querying all snippets: - -:: - - query { - snippets { - ...on Advert { - id - url - text - } - } - } - - SettingObjectType ^^^^^^^^^^^^^^^^^ diff --git a/docs/general-usage/interfaces.rst b/docs/general-usage/interfaces.rst index ea3d9d2d..55f61469 100644 --- a/docs/general-usage/interfaces.rst +++ b/docs/general-usage/interfaces.rst @@ -50,7 +50,7 @@ the name of the model: } You can change the default ``PageInterface`` to your own interface by changing the -:ref:`PAGE_INTERFACE` setting. +:ref:`PAGE_INTERFACE` setting. As mentioned above there is both a plural ``pages`` and singular ``page`` field on the root Query type that returns a ``PageInterface``. @@ -107,6 +107,35 @@ in the interface: +``SnippetInterface`` +-------------------- + +``SnippetInterface`` is the default interface for all Wagtail snippet models. It is accessible throught the +``snippets`` field on the root query type. It exposes the following fields: + +:: + + snipeptType: String! + +An example of querying all snippets: + +:: + + query { + snippets { + snippetType + ...on Advert { + id + url + text + } + } + } + +You can change the default ``SnippetInterface`` to your own interface by changing the +:ref:`SNIPPET_INTERFACE` setting. + + Adding your own interfaces -------------------------- diff --git a/docs/getting-started/settings.rst b/docs/getting-started/settings.rst index 23f37179..ac245a5a 100644 --- a/docs/getting-started/settings.rst +++ b/docs/getting-started/settings.rst @@ -141,10 +141,10 @@ Limit the maximum number of items that ``QuerySetList`` and ``PaginatedQuerySet` Default: ``100`` -.. _page interface settings: +Wagtail model interfaces +^^^^^^^^^^^^^^^^^^^^^^^^ -Wagtail Page interface -^^^^^^^^^^^^^^^^^^^^^^ +.. _page interface setting: ``PAGE_INTERFACE`` ****************** @@ -153,3 +153,14 @@ Used to construct the schema for Wagtail Page-derived models. It can be overridd page models. Default: ``grapple.types.interfaces.PageInterface`` + + +.. _snippet interface setting: + +``SNIPPET_INTERFACE`` +********************* + +Used to construct the schema for Wagtail snippet models. It can be overridden to provide a custom interface for all +snippet models. + +Default: ``grapple.types.interfaces.SnippetInterface`` diff --git a/grapple/actions.py b/grapple/actions.py index b3601b2a..3c8332c2 100644 --- a/grapple/actions.py +++ b/grapple/actions.py @@ -27,6 +27,7 @@ from .types.images import ImageObjectType, ImageRenditionObjectType from .types.pages import Page, get_page_interface from .types.rich_text import RichText as RichTextType +from .types.snippets import get_snippet_interface from .types.streamfield import generate_streamfield_union @@ -612,7 +613,7 @@ def register_snippet_model(cls: Type[models.Model], type_prefix: str): return # Create a GQL type that implements Snippet Interface - snippet_node_type = build_node_type(cls, type_prefix, None) + snippet_node_type = build_node_type(cls, type_prefix, get_snippet_interface()) if snippet_node_type: registry.snippets[cls] = snippet_node_type diff --git a/grapple/settings.py b/grapple/settings.py index 3c5d10ae..3a2154f6 100644 --- a/grapple/settings.py +++ b/grapple/settings.py @@ -29,6 +29,7 @@ "MAX_PAGE_SIZE": 100, "RICHTEXT_FORMAT": "html", "PAGE_INTERFACE": "grapple.types.interfaces.PageInterface", + "SNIPPET_INTERFACE": "grapple.types.interfaces.SnippetInterface", } # List of settings that have been deprecated diff --git a/grapple/types/interfaces.py b/grapple/types/interfaces.py index 84c41c14..eaf85daa 100644 --- a/grapple/types/interfaces.py +++ b/grapple/types/interfaces.py @@ -209,3 +209,18 @@ def resolve_raw_value(self, info, **kwargs): return self.value.source return self.value + + +def get_snippet_interface(): + return import_string(grapple_settings.SNIPPET_INTERFACE) + + +class SnippetInterface(graphene.Interface): + snippet_type = graphene.String(required=True) + + @classmethod + def resolve_type(cls, instance, info, **kwargs): + return registry.snippets[type(instance)] + + def resolve_snippet_type(self, info, **kwargs): + return self.__class__.__name__ diff --git a/grapple/types/snippets.py b/grapple/types/snippets.py index 8e496943..b948fb78 100644 --- a/grapple/types/snippets.py +++ b/grapple/types/snippets.py @@ -1,49 +1,19 @@ import graphene from ..registry import registry - - -class SnippetTypes: - # SnippetObjectType class can only be created if - # registry.snippets.types is non-empty, and should only be created - # once (graphene complains if we register multiple type classes - # with identical names) - _SnippetObjectType = None - - @classmethod - def get_object_type(cls): - if cls._SnippetObjectType is None and registry.snippets: - - class SnippetObjectType(graphene.Union): - class Meta: - types = registry.snippets.types - - cls._SnippetObjectType = SnippetObjectType - return cls._SnippetObjectType +from .interfaces import get_snippet_interface def SnippetsQuery(): - SnippetObjectType = SnippetTypes.get_object_type() - - if SnippetObjectType is not None: - - class Mixin: - snippets = graphene.List(graphene.NonNull(SnippetObjectType), required=True) - # Return all snippets. - - def resolve_snippets(self, info, **kwargs): - snippet_objects = [] - for snippet in registry.snippets: - for object in snippet._meta.model.objects.all(): - snippet_objects.append(object) - - return snippet_objects - - return Mixin + class Mixin: + snippets = graphene.List(graphene.NonNull(get_snippet_interface), required=True) - else: + def resolve_snippets(self, info, **kwargs): + snippet_objects = [] + for snippet in registry.snippets: + for object in snippet._meta.model.objects.all(): + snippet_objects.append(object) - class Mixin: - pass + return snippet_objects - return Mixin + return Mixin diff --git a/grapple/types/streamfield.py b/grapple/types/streamfield.py index 7c2aa844..4e98fa3f 100644 --- a/grapple/types/streamfield.py +++ b/grapple/types/streamfield.py @@ -353,8 +353,7 @@ def resolve_items(self, info, **kwargs): def register_streamfield_blocks(): from .documents import get_document_type from .images import get_image_type - from .interfaces import get_page_interface - from .snippets import SnippetTypes + from .interfaces import get_page_interface, get_snippet_interface class PageChooserBlock(graphene.ObjectType): page = graphene.Field(get_page_interface(), required=False) @@ -391,20 +390,17 @@ def resolve_image(self, info, **kwargs): } ) - SnippetObjectType = SnippetTypes.get_object_type() - if SnippetObjectType is not None: + class SnippetChooserBlock(graphene.ObjectType): + snippet = graphene.Field(get_snippet_interface(), required=False) - class SnippetChooserBlock(graphene.ObjectType): - snippet = graphene.Field(SnippetObjectType, required=False) - - class Meta: - interfaces = (StreamFieldInterface,) + class Meta: + interfaces = (StreamFieldInterface,) - def resolve_snippet(self, info, **kwargs): - return self.value + def resolve_snippet(self, info, **kwargs): + return self.value - registry.streamfield_blocks.update( - { - wagtail.snippets.blocks.SnippetChooserBlock: SnippetChooserBlock, - } - ) + registry.streamfield_blocks.update( + { + wagtail.snippets.blocks.SnippetChooserBlock: SnippetChooserBlock, + } + ) diff --git a/tests/settings_custom_interfaces.py b/tests/settings_custom_interfaces.py index 319403fb..31628ae1 100644 --- a/tests/settings_custom_interfaces.py +++ b/tests/settings_custom_interfaces.py @@ -2,3 +2,4 @@ GRAPPLE["PAGE_INTERFACE"] = "testapp.interfaces.CustomPageInterface" # noqa: F405 +GRAPPLE["SNIPPET_INTERFACE"] = "testapp.interfaces.CustomSnippetInterface" # noqa: F405 diff --git a/tests/test_grapple.py b/tests/test_grapple.py index 1c2445f9..20df8bc3 100644 --- a/tests/test_grapple.py +++ b/tests/test_grapple.py @@ -10,7 +10,7 @@ from django.db import connection from django.test import RequestFactory, TestCase, override_settings from graphene.test import Client -from testapp.factories import BlogPageFactory +from testapp.factories import AdvertFactory, BlogPageFactory from testapp.models import GlobalSocialMediaSettings, HomePage, SocialMediaSettings from wagtail.documents import get_document_model from wagtail.models import Page, Site @@ -1579,3 +1579,28 @@ def test_query_single_setting_without_site_filter_and_multiple_sites(self): "data": {"setting": None}, }, ) + + +class SnippetsTest(BaseGrappleTest): + def setUp(self): + super().setUp() + self.factory = RequestFactory() + self.advert = AdvertFactory() + + def test_snippets(self): + query = """ + { + snippets { + snippetType + } + } + """ + + executed = self.client.execute(query) + + self.assertEqual(type(executed["data"]), dict) + self.assertEqual(type(executed["data"]["snippets"]), list) + self.assertEqual(type(executed["data"]["snippets"][0]), dict) + + snippets_data = executed["data"]["snippets"] + self.assertEqual(snippets_data[0]["snippetType"], "Advert") diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 4e3dd23b..a1276010 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -5,9 +5,13 @@ from django.test import override_settings, tag from test_grapple import BaseGrappleTestWithIntrospection from testapp.factories import AdditionalInterfaceBlockFactory, BlogPageFactory -from testapp.interfaces import CustomPageInterface +from testapp.interfaces import CustomPageInterface, CustomSnippetInterface -from grapple.types.interfaces import PageInterface, get_page_interface +from grapple.types.interfaces import ( + PageInterface, + get_page_interface, + get_snippet_interface, +) @skipIf( @@ -41,6 +45,12 @@ def test_schema_with_default_page_interface(self): def test_get_page_interface_with_custom_page_interface(self): self.assertIs(get_page_interface(), CustomPageInterface) + @override_settings( + GRAPPLE={"SNIPPET_INTERFACE": "testapp.interfaces.CustomSnippetInterface"} + ) + def test_get_snippet_interface_with_custom_page_interface(self): + self.assertIs(get_snippet_interface(), CustomSnippetInterface) + def test_streamfield_block_with_additional_interface(self): query = """ query($id: ID) { @@ -86,7 +96,7 @@ def test_schema_for_snippet_with_graphql_interface(self): results = self.introspect_schema_by_type("Advert") self.assertListEqual( sorted(results["data"]["__type"]["interfaces"], key=lambda x: x["name"]), - [{"name": "AdditionalInterface"}], + [{"name": "AdditionalInterface"}, {"name": "SnippetInterface"}], ) def test_schema_for_django_model_with_graphql_interfaces(self): @@ -108,3 +118,10 @@ def test_schema_with_custom_page_interface(self): self.assertListEqual( results["data"]["__type"]["interfaces"], [{"name": "CustomPageInterface"}] ) + + def test_schema_with_custom_snippet_interface(self): + results = self.introspect_schema_by_type("Person") + self.assertListEqual( + results["data"]["__type"]["interfaces"], + [{"name": "CustomSnippetInterface"}], + ) diff --git a/tests/testapp/interfaces.py b/tests/testapp/interfaces.py index 08d45d0d..41baec05 100644 --- a/tests/testapp/interfaces.py +++ b/tests/testapp/interfaces.py @@ -1,6 +1,6 @@ import graphene -from grapple.types.interfaces import PageInterface +from grapple.types.interfaces import PageInterface, SnippetInterface class AdditionalInterface(graphene.Interface): @@ -9,3 +9,10 @@ class AdditionalInterface(graphene.Interface): class CustomPageInterface(PageInterface): custom_text = graphene.String() + + +class CustomSnippetInterface(SnippetInterface): + custom_text = graphene.String() + + def resolve_custom_text(self, info, **kwargs): + return str(self) diff --git a/tox.ini b/tox.ini index 589f0915..30fa9bad 100644 --- a/tox.ini +++ b/tox.ini @@ -53,7 +53,7 @@ install_command = python -Im pip install -U {opts} {packages} commands = python -m coverage run manage.py test {posargs: -v1} --exclude-tag=needs-custom-settings - python manage.py test -v1 --tag=needs-custom-settings --settings=settings_custom_page_interface + python manage.py test -v1 --tag=needs-custom-settings --settings=settings_custom_interfaces [testenv:coverage-report] ; a bit of a hack - we keep deps to a minimum, and move coverage data to the tox root for easier excludes