From d092770846f1e6425d83627cf37fe3c854f33540 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Fri, 20 Sep 2024 10:19:13 +0300 Subject: [PATCH 1/9] Rename CustomInterface to AdditionalInterface The name `CustomInterface` is too similar with `CustomPageInterface` and the upcoming `CustomSnippetInterface` and their custom interfaces settings file. Better rename it to `AdditionalInterface` so there is less confusion. --- docs/general-usage/decorators.rst | 6 +- docs/general-usage/interfaces.rst | 15 +-- tests/test_interfaces.py | 26 ++-- tests/testapp/blocks.py | 14 +- tests/testapp/factories.py | 8 +- tests/testapp/interfaces.py | 4 +- .../migrations/0003_alter_blogpage_body.py | 126 ++++++++++++++++++ tests/testapp/models/core.py | 8 +- 8 files changed, 166 insertions(+), 41 deletions(-) create mode 100644 tests/testapp/migrations/0003_alter_blogpage_body.py diff --git a/docs/general-usage/decorators.rst b/docs/general-usage/decorators.rst index cf1f41b5..1423d783 100644 --- a/docs/general-usage/decorators.rst +++ b/docs/general-usage/decorators.rst @@ -433,12 +433,12 @@ To register additional interfaces for the block, add them with your block's ``gr from grapple.helpers import register_streamfield_block - class CustomInterface(graphene.Interface): + class MyInterface(graphene.Interface): text = graphene.String() @register_streamfield_block - class CustomInterfaceBlock(blocks.StructBlock): + class MyInterfaceBlock(blocks.StructBlock): text = blocks.TextBlock() - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (MyInterface,) diff --git a/docs/general-usage/interfaces.rst b/docs/general-usage/interfaces.rst index 0b809a59..ea3d9d2d 100644 --- a/docs/general-usage/interfaces.rst +++ b/docs/general-usage/interfaces.rst @@ -118,10 +118,7 @@ Given the following example interface: .. code-block:: python # interfaces.py - from .interfaces import CustomInterface - - - class CustomInterface(graphene.Interface): + class MyInterface(graphene.Interface): custom_field = graphene.String() you could add it to your Page model like so: @@ -129,12 +126,13 @@ you could add it to your Page model like so: .. code-block:: python from wagtail.models import Page + from .interfaces import MyInterface class MyPage(Page): # ... - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (MyInterface,) or any Django model: @@ -142,13 +140,13 @@ or any Django model: # models.py from django.db import models + from .interfaces import MyInterface class MyModel(models.Model): # ... - graphql_interfaces = (CustomInterface,) - + graphql_interfaces = (MyInterface,) or a ``StreamField`` block: @@ -156,11 +154,12 @@ or a ``StreamField`` block: # blocks.py from wagtail.core import blocks + from .interfaces import MyInterface class MyStructBlock(blocks.StructBlock): # ... - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (MyInterface,) The provided interfaces will be added to the base interfaces for the model. diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 6922e35e..7cce64b8 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -4,7 +4,7 @@ from django.test import override_settings, tag from test_grapple import BaseGrappleTestWithIntrospection -from testapp.factories import BlogPageFactory, CustomInterfaceBlockFactory +from testapp.factories import AdditionalInterfaceBlockFactory, BlogPageFactory from testapp.interfaces import CustomPageInterface from grapple.types.interfaces import PageInterface, get_page_interface @@ -21,7 +21,7 @@ def setUpTestData(cls): cls.blog_page = BlogPageFactory( body=[ - ("custom_interface_block", CustomInterfaceBlockFactory()), + ("additional_interface_block", AdditionalInterfaceBlockFactory()), ], parent=cls.home, ) @@ -41,15 +41,15 @@ def test_schema_with_default_page_interface(self): def test_get_page_interface_with_custom_page_interface(self): self.assertIs(get_page_interface(), CustomPageInterface) - def test_streamfield_block_with_custom_interface(self): + def test_streamfield_block_with_additional_interface(self): query = """ query($id: ID) { page(id: $id) { ... on BlogPage { body { blockType - ...on CustomInterface { - customText + ...on AdditionalInterface { + additionalText } } } @@ -60,40 +60,40 @@ def test_streamfield_block_with_custom_interface(self): body = results["data"]["page"]["body"] for block in body: - if block["blockType"] == "CustomInterfaceBlock": + if block["blockType"] == "AdditionalInterfaceBlock": self.assertRegex( - block["customText"], r"^Block with custom interface \d+$" + block["additionalText"], r"^Block with additional interface \d+$" ) return self.fail("Query by interface didn't match anything") - def test_schema_for_streamfield_block_with_custom_interface(self): - results = self.introspect_schema_by_type("CustomInterfaceBlock") + def test_schema_for_streamfield_block_with_additional_interface(self): + results = self.introspect_schema_by_type("AdditionalInterfaceBlock") self.assertListEqual( sorted(results["data"]["__type"]["interfaces"], key=lambda x: x["name"]), - [{"name": "CustomInterface"}, {"name": "StreamFieldInterface"}], + [{"name": "AdditionalInterface"}, {"name": "StreamFieldInterface"}], ) def test_schema_for_page_with_graphql_interface(self): results = self.introspect_schema_by_type("AuthorPage") self.assertListEqual( sorted(results["data"]["__type"]["interfaces"], key=lambda x: x["name"]), - [{"name": "CustomInterface"}, {"name": "PageInterface"}], + [{"name": "AdditionalInterface"}, {"name": "PageInterface"}], ) 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": "CustomInterface"}], + [{"name": "AdditionalInterface"}], ) def test_schema_for_django_model_with_graphql_interfaces(self): results = self.introspect_schema_by_type("SimpleModel") self.assertListEqual( sorted(results["data"]["__type"]["interfaces"], key=lambda x: x["name"]), - [{"name": "CustomInterface"}], + [{"name": "AdditionalInterface"}], ) diff --git a/tests/testapp/blocks.py b/tests/testapp/blocks.py index 0888e062..f5e606b4 100644 --- a/tests/testapp/blocks.py +++ b/tests/testapp/blocks.py @@ -24,7 +24,7 @@ GraphQLStreamfield, GraphQLString, ) -from testapp.interfaces import CustomInterface +from testapp.interfaces import AdditionalInterface if TYPE_CHECKING: @@ -294,17 +294,17 @@ def get_link_url( @register_streamfield_block -class CustomInterfaceBlock(blocks.StructBlock): +class AdditionalInterfaceBlock(blocks.StructBlock): """ - Specify a custom GraphQL interface for our block. + Specify an additional GraphQL interface for our block. """ - custom_text = blocks.TextBlock() + additional_text = blocks.TextBlock() graphql_fields = [ - GraphQLString("custom_text"), + GraphQLString("additional_text"), ] - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (AdditionalInterface,) class StreamFieldBlock(blocks.StreamBlock): @@ -325,4 +325,4 @@ class StreamFieldBlock(blocks.StreamBlock): block_with_name = BlockWithName() advert = SnippetChooserBlock("testapp.Advert") person = SnippetChooserBlock("testapp.Person") - custom_interface_block = CustomInterfaceBlock() + additional_interface_block = AdditionalInterfaceBlock() diff --git a/tests/testapp/factories.py b/tests/testapp/factories.py index 35c46fde..5c66d7b7 100644 --- a/tests/testapp/factories.py +++ b/tests/testapp/factories.py @@ -9,7 +9,7 @@ from wagtail.contrib.redirects.models import Redirect from testapp.blocks import ( - CustomInterfaceBlock, + AdditionalInterfaceBlock, ImageGalleryBlock, ImageGalleryImage, ImageGalleryImages, @@ -88,11 +88,11 @@ class Meta: model = TextWithCallableBlock -class CustomInterfaceBlockFactory(wagtail_factories.StructBlockFactory): - custom_text = factory.Sequence(lambda n: f"Block with custom interface {n}") +class AdditionalInterfaceBlockFactory(wagtail_factories.StructBlockFactory): + additional_text = factory.Sequence(lambda n: f"Block with additional interface {n}") class Meta: - model = CustomInterfaceBlock + model = AdditionalInterfaceBlock class BlogPageRelatedLinkFactory(factory.django.DjangoModelFactory): diff --git a/tests/testapp/interfaces.py b/tests/testapp/interfaces.py index b473215d..08d45d0d 100644 --- a/tests/testapp/interfaces.py +++ b/tests/testapp/interfaces.py @@ -3,8 +3,8 @@ from grapple.types.interfaces import PageInterface -class CustomInterface(graphene.Interface): - custom_text = graphene.String() +class AdditionalInterface(graphene.Interface): + additional_text = graphene.String() class CustomPageInterface(PageInterface): diff --git a/tests/testapp/migrations/0003_alter_blogpage_body.py b/tests/testapp/migrations/0003_alter_blogpage_body.py new file mode 100644 index 00000000..86e0bbed --- /dev/null +++ b/tests/testapp/migrations/0003_alter_blogpage_body.py @@ -0,0 +1,126 @@ +# Generated by Django 5.0.9 on 2024-09-20 07:17 + +import wagtail.fields + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("testapp", "0002_create_homepage"), + ] + + operations = [ + migrations.AlterField( + model_name="blogpage", + name="body", + field=wagtail.fields.StreamField( + [ + ("heading", 0), + ("paragraph", 1), + ("image", 2), + ("decimal", 3), + ("date", 4), + ("datetime", 5), + ("gallery", 8), + ("video", 10), + ("objectives", 12), + ("carousel", 13), + ("callout", 14), + ("text_and_buttons", 20), + ("page", 21), + ("text_with_callable", 24), + ("block_with_name", 25), + ("advert", 26), + ("person", 27), + ("additional_interface_block", 28), + ], + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + {"form_classname": "full title"}, + ), + 1: ("wagtail.blocks.RichTextBlock", (), {}), + 2: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 3: ("wagtail.blocks.DecimalBlock", (), {}), + 4: ("wagtail.blocks.DateBlock", (), {}), + 5: ("wagtail.blocks.DateTimeBlock", (), {}), + 6: ( + "wagtail.blocks.StructBlock", + [[("caption", 0), ("image", 2)]], + {}, + ), + 7: ("wagtail.blocks.StreamBlock", [[("image", 6)]], {}), + 8: ( + "wagtail.blocks.StructBlock", + [[("title", 0), ("images", 7)]], + {}, + ), + 9: ("wagtail.embeds.blocks.EmbedBlock", (), {"required": False}), + 10: ("wagtail.blocks.StructBlock", [[("youtube_link", 9)]], {}), + 11: ("wagtail.blocks.CharBlock", (), {}), + 12: ("wagtail.blocks.ListBlock", (11,), {}), + 13: ( + "wagtail.blocks.StreamBlock", + [[("text", 0), ("image", 2), ("markup", 1)]], + {}, + ), + 14: ( + "wagtail.blocks.StructBlock", + [[("text", 1), ("image", 2)]], + {}, + ), + 15: ("wagtail.blocks.TextBlock", (), {}), + 16: ( + "wagtail.blocks.CharBlock", + (), + {"label": "Text", "max_length": 50, "required": True}, + ), + 17: ( + "wagtail.blocks.CharBlock", + (), + {"label": "Link", "max_length": 255, "required": True}, + ), + 18: ( + "wagtail.blocks.StructBlock", + [[("button_text", 16), ("button_link", 17)]], + {}, + ), + 19: ("wagtail.blocks.ListBlock", (18,), {}), + 20: ( + "wagtail.blocks.StructBlock", + [[("text", 15), ("buttons", 19), ("mainbutton", 18)]], + {}, + ), + 21: ("wagtail.blocks.PageChooserBlock", (), {}), + 22: ("wagtail.blocks.IntegerBlock", (), {}), + 23: ("wagtail.blocks.FloatBlock", (), {}), + 24: ( + "wagtail.blocks.StructBlock", + [ + [ + ("text", 11), + ("integer", 22), + ("decimal", 23), + ("page", 21), + ] + ], + {}, + ), + 25: ("wagtail.blocks.StructBlock", [[("name", 15)]], {}), + 26: ( + "wagtail.snippets.blocks.SnippetChooserBlock", + ("testapp.Advert",), + {}, + ), + 27: ( + "wagtail.snippets.blocks.SnippetChooserBlock", + ("testapp.Person",), + {}, + ), + 28: ("wagtail.blocks.StructBlock", [[("additional_text", 15)]], {}), + }, + ), + ), + ] diff --git a/tests/testapp/models/core.py b/tests/testapp/models/core.py index e010333f..732b1554 100644 --- a/tests/testapp/models/core.py +++ b/tests/testapp/models/core.py @@ -40,7 +40,7 @@ ) from grapple.utils import resolve_paginated_queryset from testapp.blocks import StreamFieldBlock -from testapp.interfaces import CustomInterface +from testapp.interfaces import AdditionalInterface document_model_string = getattr( @@ -50,7 +50,7 @@ @register_singular_query_field("simpleModel") class SimpleModel(models.Model): - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (AdditionalInterface,) def custom_middleware_one(next, root, info, **args): @@ -83,7 +83,7 @@ class AuthorPage(Page): content_panels = Page.content_panels + [FieldPanel("name")] graphql_fields = [GraphQLString("name")] - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (AdditionalInterface,) class BlogPageTag(TaggedItemBase): @@ -264,7 +264,7 @@ class Advert(models.Model): GraphQLString("string_rich_text", source="rich_text"), GraphQLString("extra_rich_text", deprecation_reason="Use rich_text instead"), ] - graphql_interfaces = (CustomInterface,) + graphql_interfaces = (AdditionalInterface,) def __str__(self): return self.text From 3205be23134cbb8f504aa5bdd9de6d67495f975e Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Fri, 20 Sep 2024 10:32:22 +0300 Subject: [PATCH 2/9] Rename the custom interface settings file The settings file will contain an override for the default snippet interface, in addition to the default page interface, so we're renaming it up front here. --- ...page_interface.py => settings_custom_interfaces.py} | 0 tests/test_interfaces.py | 10 +++++----- 2 files changed, 5 insertions(+), 5 deletions(-) rename tests/{settings_custom_page_interface.py => settings_custom_interfaces.py} (100%) diff --git a/tests/settings_custom_page_interface.py b/tests/settings_custom_interfaces.py similarity index 100% rename from tests/settings_custom_page_interface.py rename to tests/settings_custom_interfaces.py diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 7cce64b8..4e3dd23b 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -11,8 +11,8 @@ @skipIf( - os.getenv("DJANGO_SETTINGS_MODULE") == "settings_custom_page_interface", - "Cannot run with settings_custom_page_interface", + os.getenv("DJANGO_SETTINGS_MODULE") == "settings_custom_interfaces", + "Cannot run with settings_custom_interfaces", ) class InterfacesTestCase(BaseGrappleTestWithIntrospection): @classmethod @@ -99,10 +99,10 @@ def test_schema_for_django_model_with_graphql_interfaces(self): @tag("needs-custom-settings") @skipUnless( - os.getenv("DJANGO_SETTINGS_MODULE") == "settings_custom_page_interface", - "Needs settings_custom_page_interface", + os.getenv("DJANGO_SETTINGS_MODULE") == "settings_custom_interfaces", + "Needs settings_custom_interfaces", ) -class CustomPageInterfaceTestCase(BaseGrappleTestWithIntrospection): +class CustomInterfacesTestCase(BaseGrappleTestWithIntrospection): def test_schema_with_custom_page_interface(self): results = self.introspect_schema_by_type("BlogPage") self.assertListEqual( From e2729f4b2bc704d7bccb2db19cf6191dce570dca Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Fri, 20 Sep 2024 12:12:16 +0300 Subject: [PATCH 3/9] 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 From 50cd83a5964f86953b464761927d9f65e689ef21 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Fri, 20 Sep 2024 14:06:35 +0300 Subject: [PATCH 4/9] Update changelog --- CHANGELOG.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4ff0a895..2259bd85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ ## Unreleased +### Changed + +- `SnippetObjectType` is replaced with `SnippetInterface` ([405](https://github.com/torchbox/wagtail-grapple/pull/405)) @mgax + ### Fixed - `value` not being queryable on `EmbedBlock` ([#399](https://github.com/torchbox/wagtail-grapple/pull/399))@JakubMastalerz From 33edb50b4522a2c5cbabd3186ed7a920a7f6591b Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 23 Sep 2024 10:43:17 +0300 Subject: [PATCH 5/9] Fix typo --- docs/general-usage/interfaces.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/general-usage/interfaces.rst b/docs/general-usage/interfaces.rst index 55f61469..c27441bb 100644 --- a/docs/general-usage/interfaces.rst +++ b/docs/general-usage/interfaces.rst @@ -110,7 +110,7 @@ in the interface: ``SnippetInterface`` -------------------- -``SnippetInterface`` is the default interface for all Wagtail snippet models. It is accessible throught the +``SnippetInterface`` is the default interface for all Wagtail snippet models. It is accessible through the ``snippets`` field on the root query type. It exposes the following fields: :: From 34a3c3b0e85cc79c1b220be756921b70e369ddb1 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 23 Sep 2024 12:21:10 +0300 Subject: [PATCH 6/9] Fold the new migration into `0001` --- tests/testapp/migrations/0001_initial.py | 278 +++++++----------- .../migrations/0003_alter_blogpage_body.py | 126 -------- 2 files changed, 104 insertions(+), 300 deletions(-) delete mode 100644 tests/testapp/migrations/0003_alter_blogpage_body.py diff --git a/tests/testapp/migrations/0001_initial.py b/tests/testapp/migrations/0001_initial.py index d09e18b1..d405ab03 100644 --- a/tests/testapp/migrations/0001_initial.py +++ b/tests/testapp/migrations/0001_initial.py @@ -92,193 +92,123 @@ class Migration(migrations.Migration): "body", wagtail.fields.StreamField( [ - ( - "heading", - wagtail.blocks.CharBlock(form_classname="full title"), + ("heading", 0), + ("paragraph", 1), + ("image", 2), + ("decimal", 3), + ("date", 4), + ("datetime", 5), + ("gallery", 8), + ("video", 10), + ("objectives", 12), + ("carousel", 13), + ("callout", 14), + ("text_and_buttons", 20), + ("page", 21), + ("text_with_callable", 24), + ("block_with_name", 25), + ("advert", 26), + ("person", 27), + ("additional_interface_block", 28), + ], + block_lookup={ + 0: ( + "wagtail.blocks.CharBlock", + (), + {"form_classname": "full title"}, ), - ("paragraph", wagtail.blocks.RichTextBlock()), - ("image", wagtail.images.blocks.ImageChooserBlock()), - ("decimal", wagtail.blocks.DecimalBlock()), - ("date", wagtail.blocks.DateBlock()), - ("datetime", wagtail.blocks.DateTimeBlock()), - ( - "gallery", - wagtail.blocks.StructBlock( - [ - ( - "title", - wagtail.blocks.CharBlock( - form_classname="full title" - ), - ), - ( - "images", - wagtail.blocks.StreamBlock( - [ - ( - "image", - wagtail.blocks.StructBlock( - [ - ( - "caption", - wagtail.blocks.CharBlock( - form_classname="full title" - ), - ), - ( - "image", - wagtail.images.blocks.ImageChooserBlock(), - ), - ] - ), - ) - ] - ), - ), - ] - ), + 1: ("wagtail.blocks.RichTextBlock", (), {}), + 2: ("wagtail.images.blocks.ImageChooserBlock", (), {}), + 3: ("wagtail.blocks.DecimalBlock", (), {}), + 4: ("wagtail.blocks.DateBlock", (), {}), + 5: ("wagtail.blocks.DateTimeBlock", (), {}), + 6: ( + "wagtail.blocks.StructBlock", + [[("caption", 0), ("image", 2)]], + {}, ), - ( - "video", - wagtail.blocks.StructBlock( - [ - ( - "youtube_link", - wagtail.embeds.blocks.EmbedBlock( - required=False - ), - ) - ] - ), + 7: ("wagtail.blocks.StreamBlock", [[("image", 6)]], {}), + 8: ( + "wagtail.blocks.StructBlock", + [[("title", 0), ("images", 7)]], + {}, ), - ( - "objectives", - wagtail.blocks.ListBlock(wagtail.blocks.CharBlock()), + 9: ( + "wagtail.embeds.blocks.EmbedBlock", + (), + {"required": False}, ), - ( - "carousel", - wagtail.blocks.StreamBlock( - [ - ( - "text", - wagtail.blocks.CharBlock( - form_classname="full title" - ), - ), - ( - "image", - wagtail.images.blocks.ImageChooserBlock(), - ), - ("markup", wagtail.blocks.RichTextBlock()), - ] - ), + 10: ( + "wagtail.blocks.StructBlock", + [[("youtube_link", 9)]], + {}, ), - ( - "callout", - wagtail.blocks.StructBlock( - [ - ("text", wagtail.blocks.RichTextBlock()), - ( - "image", - wagtail.images.blocks.ImageChooserBlock(), - ), - ] - ), + 11: ("wagtail.blocks.CharBlock", (), {}), + 12: ("wagtail.blocks.ListBlock", (11,), {}), + 13: ( + "wagtail.blocks.StreamBlock", + [[("text", 0), ("image", 2), ("markup", 1)]], + {}, ), - ( - "text_and_buttons", - wagtail.blocks.StructBlock( - [ - ("text", wagtail.blocks.TextBlock()), - ( - "buttons", - wagtail.blocks.ListBlock( - wagtail.blocks.StructBlock( - [ - ( - "button_text", - wagtail.blocks.CharBlock( - label="Text", - max_length=50, - required=True, - ), - ), - ( - "button_link", - wagtail.blocks.CharBlock( - label="Link", - max_length=255, - required=True, - ), - ), - ] - ) - ), - ), - ( - "mainbutton", - wagtail.blocks.StructBlock( - [ - ( - "button_text", - wagtail.blocks.CharBlock( - label="Text", - max_length=50, - required=True, - ), - ), - ( - "button_link", - wagtail.blocks.CharBlock( - label="Link", - max_length=255, - required=True, - ), - ), - ] - ), - ), - ] - ), + 14: ( + "wagtail.blocks.StructBlock", + [[("text", 1), ("image", 2)]], + {}, + ), + 15: ("wagtail.blocks.TextBlock", (), {}), + 16: ( + "wagtail.blocks.CharBlock", + (), + {"label": "Text", "max_length": 50, "required": True}, + ), + 17: ( + "wagtail.blocks.CharBlock", + (), + {"label": "Link", "max_length": 255, "required": True}, ), - ("page", wagtail.blocks.PageChooserBlock()), - ( - "text_with_callable", - wagtail.blocks.StructBlock( + 18: ( + "wagtail.blocks.StructBlock", + [[("button_text", 16), ("button_link", 17)]], + {}, + ), + 19: ("wagtail.blocks.ListBlock", (18,), {}), + 20: ( + "wagtail.blocks.StructBlock", + [[("text", 15), ("buttons", 19), ("mainbutton", 18)]], + {}, + ), + 21: ("wagtail.blocks.PageChooserBlock", (), {}), + 22: ("wagtail.blocks.IntegerBlock", (), {}), + 23: ("wagtail.blocks.FloatBlock", (), {}), + 24: ( + "wagtail.blocks.StructBlock", + [ [ - ("text", wagtail.blocks.CharBlock()), - ("integer", wagtail.blocks.IntegerBlock()), - ("decimal", wagtail.blocks.FloatBlock()), - ("page", wagtail.blocks.PageChooserBlock()), + ("text", 11), + ("integer", 22), + ("decimal", 23), + ("page", 21), ] - ), + ], + {}, ), - ( - "block_with_name", - wagtail.blocks.StructBlock( - [("name", wagtail.blocks.TextBlock())] - ), + 25: ("wagtail.blocks.StructBlock", [[("name", 15)]], {}), + 26: ( + "wagtail.snippets.blocks.SnippetChooserBlock", + ("testapp.Advert",), + {}, ), - ( - "advert", - wagtail.snippets.blocks.SnippetChooserBlock( - "testapp.Advert" - ), + 27: ( + "wagtail.snippets.blocks.SnippetChooserBlock", + ("testapp.Person",), + {}, ), - ( - "person", - wagtail.snippets.blocks.SnippetChooserBlock( - "testapp.Person" - ), + 28: ( + "wagtail.blocks.StructBlock", + [[("additional_text", 15)]], + {}, ), - ( - "custom_interface_block", - wagtail.blocks.StructBlock( - [("custom_text", wagtail.blocks.TextBlock())] - ), - ), - ], - use_json_field=True, + }, ), ), ( diff --git a/tests/testapp/migrations/0003_alter_blogpage_body.py b/tests/testapp/migrations/0003_alter_blogpage_body.py deleted file mode 100644 index 86e0bbed..00000000 --- a/tests/testapp/migrations/0003_alter_blogpage_body.py +++ /dev/null @@ -1,126 +0,0 @@ -# Generated by Django 5.0.9 on 2024-09-20 07:17 - -import wagtail.fields - -from django.db import migrations - - -class Migration(migrations.Migration): - dependencies = [ - ("testapp", "0002_create_homepage"), - ] - - operations = [ - migrations.AlterField( - model_name="blogpage", - name="body", - field=wagtail.fields.StreamField( - [ - ("heading", 0), - ("paragraph", 1), - ("image", 2), - ("decimal", 3), - ("date", 4), - ("datetime", 5), - ("gallery", 8), - ("video", 10), - ("objectives", 12), - ("carousel", 13), - ("callout", 14), - ("text_and_buttons", 20), - ("page", 21), - ("text_with_callable", 24), - ("block_with_name", 25), - ("advert", 26), - ("person", 27), - ("additional_interface_block", 28), - ], - block_lookup={ - 0: ( - "wagtail.blocks.CharBlock", - (), - {"form_classname": "full title"}, - ), - 1: ("wagtail.blocks.RichTextBlock", (), {}), - 2: ("wagtail.images.blocks.ImageChooserBlock", (), {}), - 3: ("wagtail.blocks.DecimalBlock", (), {}), - 4: ("wagtail.blocks.DateBlock", (), {}), - 5: ("wagtail.blocks.DateTimeBlock", (), {}), - 6: ( - "wagtail.blocks.StructBlock", - [[("caption", 0), ("image", 2)]], - {}, - ), - 7: ("wagtail.blocks.StreamBlock", [[("image", 6)]], {}), - 8: ( - "wagtail.blocks.StructBlock", - [[("title", 0), ("images", 7)]], - {}, - ), - 9: ("wagtail.embeds.blocks.EmbedBlock", (), {"required": False}), - 10: ("wagtail.blocks.StructBlock", [[("youtube_link", 9)]], {}), - 11: ("wagtail.blocks.CharBlock", (), {}), - 12: ("wagtail.blocks.ListBlock", (11,), {}), - 13: ( - "wagtail.blocks.StreamBlock", - [[("text", 0), ("image", 2), ("markup", 1)]], - {}, - ), - 14: ( - "wagtail.blocks.StructBlock", - [[("text", 1), ("image", 2)]], - {}, - ), - 15: ("wagtail.blocks.TextBlock", (), {}), - 16: ( - "wagtail.blocks.CharBlock", - (), - {"label": "Text", "max_length": 50, "required": True}, - ), - 17: ( - "wagtail.blocks.CharBlock", - (), - {"label": "Link", "max_length": 255, "required": True}, - ), - 18: ( - "wagtail.blocks.StructBlock", - [[("button_text", 16), ("button_link", 17)]], - {}, - ), - 19: ("wagtail.blocks.ListBlock", (18,), {}), - 20: ( - "wagtail.blocks.StructBlock", - [[("text", 15), ("buttons", 19), ("mainbutton", 18)]], - {}, - ), - 21: ("wagtail.blocks.PageChooserBlock", (), {}), - 22: ("wagtail.blocks.IntegerBlock", (), {}), - 23: ("wagtail.blocks.FloatBlock", (), {}), - 24: ( - "wagtail.blocks.StructBlock", - [ - [ - ("text", 11), - ("integer", 22), - ("decimal", 23), - ("page", 21), - ] - ], - {}, - ), - 25: ("wagtail.blocks.StructBlock", [[("name", 15)]], {}), - 26: ( - "wagtail.snippets.blocks.SnippetChooserBlock", - ("testapp.Advert",), - {}, - ), - 27: ( - "wagtail.snippets.blocks.SnippetChooserBlock", - ("testapp.Person",), - {}, - ), - 28: ("wagtail.blocks.StructBlock", [[("additional_text", 15)]], {}), - }, - ), - ), - ] From 09ea03bdda8a6b78dbb1484f7d9adfbbaa42adb8 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Mon, 23 Sep 2024 13:52:51 +0300 Subject: [PATCH 7/9] Add contentType field on SnippetInterface --- docs/general-usage/interfaces.rst | 4 +++- grapple/types/interfaces.py | 7 +++++++ tests/test_grapple.py | 2 ++ 3 files changed, 12 insertions(+), 1 deletion(-) diff --git a/docs/general-usage/interfaces.rst b/docs/general-usage/interfaces.rst index c27441bb..166fb0f8 100644 --- a/docs/general-usage/interfaces.rst +++ b/docs/general-usage/interfaces.rst @@ -115,7 +115,8 @@ in the interface: :: - snipeptType: String! + snippetType: String! + contentType: String! An example of querying all snippets: @@ -124,6 +125,7 @@ An example of querying all snippets: query { snippets { snippetType + contentType ...on Advert { id url diff --git a/grapple/types/interfaces.py b/grapple/types/interfaces.py index eaf85daa..5ebfc1d9 100644 --- a/grapple/types/interfaces.py +++ b/grapple/types/interfaces.py @@ -217,6 +217,7 @@ def get_snippet_interface(): class SnippetInterface(graphene.Interface): snippet_type = graphene.String(required=True) + content_type = graphene.String(required=True) @classmethod def resolve_type(cls, instance, info, **kwargs): @@ -224,3 +225,9 @@ def resolve_type(cls, instance, info, **kwargs): def resolve_snippet_type(self, info, **kwargs): return self.__class__.__name__ + + def resolve_content_type(self, info, **kwargs): + self.content_type = ContentType.objects.get_for_model(self) + return ( + f"{self.content_type.app_label}.{self.content_type.model_class().__name__}" + ) diff --git a/tests/test_grapple.py b/tests/test_grapple.py index 20df8bc3..c56d0f92 100644 --- a/tests/test_grapple.py +++ b/tests/test_grapple.py @@ -1592,6 +1592,7 @@ def test_snippets(self): { snippets { snippetType + contentType } } """ @@ -1604,3 +1605,4 @@ def test_snippets(self): snippets_data = executed["data"]["snippets"] self.assertEqual(snippets_data[0]["snippetType"], "Advert") + self.assertEqual(snippets_data[0]["contentType"], "testapp.Advert") From 4cb019aaecec0bb0305a00351793b8947b0429be Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Tue, 24 Sep 2024 17:06:56 +0300 Subject: [PATCH 8/9] Test for no registered snippets --- tests/test_grapple.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/tests/test_grapple.py b/tests/test_grapple.py index c56d0f92..74e5c6fa 100644 --- a/tests/test_grapple.py +++ b/tests/test_grapple.py @@ -16,6 +16,7 @@ from wagtail.models import Page, Site from wagtailmedia.models import get_media_model +from grapple.registry import RegistryItem from grapple.schema import create_schema @@ -1606,3 +1607,25 @@ def test_snippets(self): snippets_data = executed["data"]["snippets"] self.assertEqual(snippets_data[0]["snippetType"], "Advert") self.assertEqual(snippets_data[0]["contentType"], "testapp.Advert") + + def test_no_snippet_classes_registered(self): + """ + If there are no registered snippet classes, the snippets query should + still work, and return nothing. + """ + + query = """ + { + snippets { + snippetType + contentType + } + } + """ + + with patch("grapple.registry.registry.snippets", RegistryItem()): + executed = self.client.execute(query) + + self.assertEqual(type(executed["data"]), dict) + self.assertEqual(type(executed["data"]["snippets"]), list) + self.assertEqual(len(executed["data"]["snippets"]), 0) From 40a16ac8566734e9baa7157081fb94b77ef74d51 Mon Sep 17 00:00:00 2001 From: Alex Morega Date: Tue, 24 Sep 2024 17:11:50 +0300 Subject: [PATCH 9/9] Create an instance each of both snippet types --- tests/test_grapple.py | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/tests/test_grapple.py b/tests/test_grapple.py index 74e5c6fa..cb3ba133 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 AdvertFactory, BlogPageFactory +from testapp.factories import AdvertFactory, BlogPageFactory, PersonFactory from testapp.models import GlobalSocialMediaSettings, HomePage, SocialMediaSettings from wagtail.documents import get_document_model from wagtail.models import Page, Site @@ -1587,8 +1587,14 @@ def setUp(self): super().setUp() self.factory = RequestFactory() self.advert = AdvertFactory() + self.person = PersonFactory() def test_snippets(self): + """ + Query for snippets of different types, they should all be returned in + the same response. + """ + query = """ { snippets { @@ -1602,11 +1608,16 @@ def test_snippets(self): self.assertEqual(type(executed["data"]), dict) self.assertEqual(type(executed["data"]["snippets"]), list) + self.assertEqual(len(executed["data"]["snippets"]), 2) self.assertEqual(type(executed["data"]["snippets"][0]), dict) - snippets_data = executed["data"]["snippets"] + snippets_data = sorted( + executed["data"]["snippets"], key=lambda s: s["snippetType"] + ) self.assertEqual(snippets_data[0]["snippetType"], "Advert") self.assertEqual(snippets_data[0]["contentType"], "testapp.Advert") + self.assertEqual(snippets_data[1]["snippetType"], "Person") + self.assertEqual(snippets_data[1]["contentType"], "testapp.Person") def test_no_snippet_classes_registered(self): """