diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eb395266..8cf619fb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,6 +56,7 @@ jobs: path: tests/.coverage* if-no-files-found: ignore retention-days: 1 + include-hidden-files: true tests-postgres: runs-on: ubuntu-latest @@ -105,6 +106,7 @@ jobs: path: tests/.coverage* if-no-files-found: ignore retention-days: 1 + include-hidden-files: true coverage: runs-on: ubuntu-latest diff --git a/CHANGELOG.md b/CHANGELOG.md index 3da9eb32..4ff0a895 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ### Fixed - `value` not being queryable on `EmbedBlock` ([#399](https://github.com/torchbox/wagtail-grapple/pull/399))@JakubMastalerz +- Circular import when defining custom `PageInterface` ([#404](https://github.com/torchbox/wagtail-grapple/pull/404)) @mgax ## [0.26.0] - 2024-06-26 diff --git a/docs/getting-started/settings.rst b/docs/getting-started/settings.rst index 8d3d0aad..23f37179 100644 --- a/docs/getting-started/settings.rst +++ b/docs/getting-started/settings.rst @@ -152,4 +152,4 @@ Wagtail Page interface Used to construct the schema for Wagtail Page-derived models. It can be overridden to provide a custom interface for all page models. -Default: ``grapple.types.pages.PageInterface`` +Default: ``grapple.types.interfaces.PageInterface`` diff --git a/grapple/models.py b/grapple/models.py index 5c8d9920..2b094338 100644 --- a/grapple/models.py +++ b/grapple/models.py @@ -151,7 +151,7 @@ def Mixin(): def GraphQLPage(field_name: str, **kwargs): def Mixin(): - from .types.pages import get_page_interface + from .types.interfaces import get_page_interface return GraphQLField(field_name, get_page_interface(), **kwargs) diff --git a/grapple/settings.py b/grapple/settings.py index c543eca5..3c5d10ae 100644 --- a/grapple/settings.py +++ b/grapple/settings.py @@ -28,7 +28,7 @@ "PAGE_SIZE": 10, "MAX_PAGE_SIZE": 100, "RICHTEXT_FORMAT": "html", - "PAGE_INTERFACE": "grapple.types.pages.PageInterface", + "PAGE_INTERFACE": "grapple.types.interfaces.PageInterface", } # List of settings that have been deprecated diff --git a/grapple/types/interfaces.py b/grapple/types/interfaces.py new file mode 100644 index 00000000..84c41c14 --- /dev/null +++ b/grapple/types/interfaces.py @@ -0,0 +1,211 @@ +import inspect + +import graphene + +from django.contrib.contenttypes.models import ContentType +from django.utils.module_loading import import_string +from graphql import GraphQLError +from wagtail import blocks +from wagtail.models import Page as WagtailPage +from wagtail.rich_text import RichText + +from ..registry import registry +from ..settings import grapple_settings +from ..utils import resolve_queryset, serialize_struct_obj +from .structures import QuerySetList + + +def get_page_interface(): + return import_string(grapple_settings.PAGE_INTERFACE) + + +class PageInterface(graphene.Interface): + id = graphene.ID() + title = graphene.String(required=True) + slug = graphene.String(required=True) + content_type = graphene.String(required=True) + page_type = graphene.String() + live = graphene.Boolean(required=True) + + url = graphene.String() + url_path = graphene.String(required=True) + + depth = graphene.Int() + seo_title = graphene.String(required=True) + search_description = graphene.String() + show_in_menus = graphene.Boolean(required=True) + + locked = graphene.Boolean() + + first_published_at = graphene.DateTime() + last_published_at = graphene.DateTime() + + parent = graphene.Field(get_page_interface) + children = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + siblings = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + next_siblings = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + previous_siblings = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + descendants = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + ancestors = QuerySetList( + graphene.NonNull(get_page_interface), enable_search=True, required=True + ) + + search_score = graphene.Float() + + @classmethod + def resolve_type(cls, instance, info, **kwargs): + """ + If model has a custom Graphene Node type in registry then use it, + otherwise use base page type. + """ + from .pages import Page + + return registry.pages.get(type(instance), Page) + + 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__}" + ) + + def resolve_page_type(self, info, **kwargs): + return get_page_interface().resolve_type(self.specific, info, **kwargs) + + def resolve_parent(self, info, **kwargs): + """ + Resolves the parent node of current page node. + Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_parent + """ + try: + return self.get_parent().specific + except GraphQLError: + return WagtailPage.objects.none() + + def resolve_children(self, info, **kwargs): + """ + Resolves a list of live children of this page. + Docs: https://docs.wagtail.io/en/stable/reference/pages/queryset_reference.html#examples + """ + return resolve_queryset( + self.get_children().live().public().specific(), info, **kwargs + ) + + def resolve_siblings(self, info, **kwargs): + """ + Resolves a list of sibling nodes to this page. + Docs: https://docs.wagtail.io/en/stable/reference/pages/queryset_reference.html?highlight=get_siblings#wagtail.query.PageQuerySet.sibling_of + """ + return resolve_queryset( + self.get_siblings().exclude(pk=self.pk).live().public().specific(), + info, + **kwargs, + ) + + def resolve_next_siblings(self, info, **kwargs): + """ + Resolves a list of direct next siblings of this page. Similar to `resolve_siblings` with sorting. + Source: https://github.com/wagtail/wagtail/blob/master/wagtail/core/models.py#L1384 + """ + return resolve_queryset( + self.get_next_siblings().exclude(pk=self.pk).live().public().specific(), + info, + **kwargs, + ) + + def resolve_previous_siblings(self, info, **kwargs): + """ + Resolves a list of direct prev siblings of this page. Similar to `resolve_siblings` with sorting. + Source: https://github.com/wagtail/wagtail/blob/master/wagtail/core/models.py#L1387 + """ + return resolve_queryset( + self.get_prev_siblings().exclude(pk=self.pk).live().public().specific(), + info, + **kwargs, + ) + + def resolve_descendants(self, info, **kwargs): + """ + Resolves a list of nodes pointing to the current page’s descendants. + Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_descendants + """ + return resolve_queryset( + self.get_descendants().live().public().specific(), info, **kwargs + ) + + def resolve_ancestors(self, info, **kwargs): + """ + Resolves a list of nodes pointing to the current page’s ancestors. + Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_ancestors + """ + return resolve_queryset( + self.get_ancestors().live().public().specific(), info, **kwargs + ) + + def resolve_seo_title(self, info, **kwargs): + """ + Get page's SEO title. Fallback to a normal page's title if absent. + """ + return self.seo_title or self.title + + def resolve_search_score(self, info, **kwargs): + """ + Get page's search score, will be None if not in a search context. + """ + return getattr(self, "search_score", None) + + +class StreamFieldInterface(graphene.Interface): + id = graphene.String() + block_type = graphene.String(required=True) + field = graphene.String(required=True) + raw_value = graphene.String(required=True) + + @classmethod + def resolve_type(cls, instance, info): + """ + If block has a custom Graphene Node type in registry then use it, + otherwise use generic block type. + """ + if hasattr(instance, "block"): + mdl = type(instance.block) + if mdl in registry.streamfield_blocks: + return registry.streamfield_blocks[mdl] + + for block_class in inspect.getmro(mdl): + if block_class in registry.streamfield_blocks: + return registry.streamfield_blocks[block_class] + + return registry.streamfield_blocks["generic-block"] + + def resolve_id(self, info, **kwargs): + return self.id + + def resolve_block_type(self, info, **kwargs): + return type(self.block).__name__ + + def resolve_field(self, info, **kwargs): + return self.block.name + + def resolve_raw_value(self, info, **kwargs): + if isinstance(self, blocks.StructValue): + # This is the value for a nested StructBlock defined via GraphQLStreamfield + return serialize_struct_obj(self) + elif isinstance(self.value, dict): + return serialize_struct_obj(self.value) + elif isinstance(self.value, RichText): + # Ensure RichTextBlock raw value always returns the "internal format", rather than the conterted value + # as per https://docs.wagtail.io/en/stable/extending/rich_text_internals.html#data-format. + # Note that RichTextBlock.value will be rendered HTML by default. + return self.value.source + + return self.value diff --git a/grapple/types/pages.py b/grapple/types/pages.py index 58a460db..98e35456 100644 --- a/grapple/types/pages.py +++ b/grapple/types/pages.py @@ -2,7 +2,6 @@ from django.contrib.contenttypes.models import ContentType from django.db.models import Q -from django.utils.module_loading import import_string from django.utils.translation import gettext_lazy as _ from graphene_django.types import DjangoObjectType from graphql import GraphQLError @@ -10,158 +9,11 @@ from wagtail.models import Site from ..registry import registry -from ..settings import grapple_settings from ..utils import resolve_queryset, resolve_site_by_hostname +from .interfaces import get_page_interface from .structures import QuerySetList -def get_page_interface(): - return import_string(grapple_settings.PAGE_INTERFACE) - - -class PageInterface(graphene.Interface): - id = graphene.ID() - title = graphene.String(required=True) - slug = graphene.String(required=True) - content_type = graphene.String(required=True) - page_type = graphene.String() - live = graphene.Boolean(required=True) - - url = graphene.String() - url_path = graphene.String(required=True) - - depth = graphene.Int() - seo_title = graphene.String(required=True) - search_description = graphene.String() - show_in_menus = graphene.Boolean(required=True) - - locked = graphene.Boolean() - - first_published_at = graphene.DateTime() - last_published_at = graphene.DateTime() - - parent = graphene.Field(get_page_interface) - children = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - siblings = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - next_siblings = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - previous_siblings = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - descendants = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - ancestors = QuerySetList( - graphene.NonNull(get_page_interface), enable_search=True, required=True - ) - - search_score = graphene.Float() - - @classmethod - def resolve_type(cls, instance, info, **kwargs): - """ - If model has a custom Graphene Node type in registry then use it, - otherwise use base page type. - """ - return registry.pages.get(type(instance), Page) - - 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__}" - ) - - def resolve_page_type(self, info, **kwargs): - return get_page_interface().resolve_type(self.specific, info, **kwargs) - - def resolve_parent(self, info, **kwargs): - """ - Resolves the parent node of current page node. - Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_parent - """ - try: - return self.get_parent().specific - except GraphQLError: - return WagtailPage.objects.none() - - def resolve_children(self, info, **kwargs): - """ - Resolves a list of live children of this page. - Docs: https://docs.wagtail.io/en/stable/reference/pages/queryset_reference.html#examples - """ - return resolve_queryset( - self.get_children().live().public().specific(), info, **kwargs - ) - - def resolve_siblings(self, info, **kwargs): - """ - Resolves a list of sibling nodes to this page. - Docs: https://docs.wagtail.io/en/stable/reference/pages/queryset_reference.html?highlight=get_siblings#wagtail.query.PageQuerySet.sibling_of - """ - return resolve_queryset( - self.get_siblings().exclude(pk=self.pk).live().public().specific(), - info, - **kwargs, - ) - - def resolve_next_siblings(self, info, **kwargs): - """ - Resolves a list of direct next siblings of this page. Similar to `resolve_siblings` with sorting. - Source: https://github.com/wagtail/wagtail/blob/master/wagtail/core/models.py#L1384 - """ - return resolve_queryset( - self.get_next_siblings().exclude(pk=self.pk).live().public().specific(), - info, - **kwargs, - ) - - def resolve_previous_siblings(self, info, **kwargs): - """ - Resolves a list of direct prev siblings of this page. Similar to `resolve_siblings` with sorting. - Source: https://github.com/wagtail/wagtail/blob/master/wagtail/core/models.py#L1387 - """ - return resolve_queryset( - self.get_prev_siblings().exclude(pk=self.pk).live().public().specific(), - info, - **kwargs, - ) - - def resolve_descendants(self, info, **kwargs): - """ - Resolves a list of nodes pointing to the current page’s descendants. - Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_descendants - """ - return resolve_queryset( - self.get_descendants().live().public().specific(), info, **kwargs - ) - - def resolve_ancestors(self, info, **kwargs): - """ - Resolves a list of nodes pointing to the current page’s ancestors. - Docs: https://docs.wagtail.io/en/stable/reference/pages/model_reference.html#wagtail.models.Page.get_ancestors - """ - return resolve_queryset( - self.get_ancestors().live().public().specific(), info, **kwargs - ) - - def resolve_seo_title(self, info, **kwargs): - """ - Get page's SEO title. Fallback to a normal page's title if absent. - """ - return self.seo_title or self.title - - def resolve_search_score(self, info, **kwargs): - """ - Get page's search score, will be None if not in a search context. - """ - return getattr(self, "search_score", None) - - class Page(DjangoObjectType): """ Base Page type used if one isn't generated for the current model. diff --git a/grapple/types/redirects.py b/grapple/types/redirects.py index 540f3f0b..23085f23 100644 --- a/grapple/types/redirects.py +++ b/grapple/types/redirects.py @@ -9,7 +9,7 @@ from grapple.types.sites import SiteObjectType -from .pages import get_page_interface +from .interfaces import get_page_interface class RedirectObjectType(graphene.ObjectType): diff --git a/grapple/types/sites.py b/grapple/types/sites.py index 87c9c08c..51358fd8 100644 --- a/grapple/types/sites.py +++ b/grapple/types/sites.py @@ -10,7 +10,8 @@ from wagtail.models import Site from ..utils import resolve_queryset, resolve_site_by_hostname, resolve_site_by_id -from .pages import get_page_interface, get_specific_page +from .interfaces import get_page_interface +from .pages import get_specific_page from .structures import QuerySetList diff --git a/grapple/types/streamfield.py b/grapple/types/streamfield.py index f979ffbd..7c2aa844 100644 --- a/grapple/types/streamfield.py +++ b/grapple/types/streamfield.py @@ -1,5 +1,3 @@ -import inspect - from typing import Optional import graphene @@ -18,9 +16,9 @@ from wagtail.embeds.embeds import get_embed from wagtail.embeds.exceptions import EmbedException from wagtail.fields import StreamField -from wagtail.rich_text import RichText from ..registry import registry +from .interfaces import StreamFieldInterface from .rich_text import RichText as RichTextType @@ -40,53 +38,6 @@ def convert_stream_field(field, registry=None): ) -class StreamFieldInterface(graphene.Interface): - id = graphene.String() - block_type = graphene.String(required=True) - field = graphene.String(required=True) - raw_value = graphene.String(required=True) - - @classmethod - def resolve_type(cls, instance, info): - """ - If block has a custom Graphene Node type in registry then use it, - otherwise use generic block type. - """ - if hasattr(instance, "block"): - mdl = type(instance.block) - if mdl in registry.streamfield_blocks: - return registry.streamfield_blocks[mdl] - - for block_class in inspect.getmro(mdl): - if block_class in registry.streamfield_blocks: - return registry.streamfield_blocks[block_class] - - return registry.streamfield_blocks["generic-block"] - - def resolve_id(self, info, **kwargs): - return self.id - - def resolve_block_type(self, info, **kwargs): - return type(self.block).__name__ - - def resolve_field(self, info, **kwargs): - return self.block.name - - def resolve_raw_value(self, info, **kwargs): - if isinstance(self, blocks.StructValue): - # This is the value for a nested StructBlock defined via GraphQLStreamfield - return serialize_struct_obj(self) - elif isinstance(self.value, dict): - return serialize_struct_obj(self.value) - elif isinstance(self.value, RichText): - # Ensure RichTextBlock raw value always returns the "internal format", rather than the conterted value - # as per https://docs.wagtail.io/en/stable/extending/rich_text_internals.html#data-format. - # Note that RichTextBlock.value will be rendered HTML by default. - return self.value.source - - return self.value - - def generate_streamfield_union(graphql_types): class StreamfieldUnion(graphene.Union): class Meta: @@ -118,41 +69,6 @@ def __init__(self, id, block, value=""): self.value = value -def serialize_struct_obj(obj): - rtn_obj = {} - - if hasattr(obj, "raw_data"): - rtn_obj = [] - for field in obj[0]: - rtn_obj.append(serialize_struct_obj(field.value)) - # This conditionnal and below support both Wagtail >= 2.13 and <2.12 versions. - # The "stream_data" check can be dropped once 2.11 is not supported anymore. - # Cf: https://docs.wagtail.io/en/stable/releases/2.12.html#stream-data-on-streamfield-values-is-deprecated - elif hasattr(obj, "stream_data"): - rtn_obj = [] - for field in obj.stream_data: - rtn_obj.append(serialize_struct_obj(field["value"])) - else: - for field in obj: - value = obj[field] - if hasattr(value, "raw_data"): - rtn_obj[field] = [serialize_struct_obj(data.value) for data in value[0]] - elif hasattr(obj, "stream_data"): - rtn_obj[field] = [ - serialize_struct_obj(data["value"]) for data in value.stream_data - ] - elif hasattr(value, "value"): - rtn_obj[field] = value.value - elif hasattr(value, "src"): - rtn_obj[field] = value.src - elif hasattr(value, "file"): - rtn_obj[field] = value.file.url - else: - rtn_obj[field] = value - - return rtn_obj - - class StructBlock(graphene.ObjectType): class Meta: interfaces = (StreamFieldInterface,) @@ -437,7 +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 .pages import get_page_interface + from .interfaces import get_page_interface from .snippets import SnippetTypes class PageChooserBlock(graphene.ObjectType): diff --git a/grapple/utils.py b/grapple/utils.py index ede62155..e4c9dafa 100644 --- a/grapple/utils.py +++ b/grapple/utils.py @@ -248,3 +248,38 @@ def get_media_item_url(cls): if url[0] == "/": return settings.BASE_URL + url return url + + +def serialize_struct_obj(obj): + rtn_obj = {} + + if hasattr(obj, "raw_data"): + rtn_obj = [] + for field in obj[0]: + rtn_obj.append(serialize_struct_obj(field.value)) + # This conditionnal and below support both Wagtail >= 2.13 and <2.12 versions. + # The "stream_data" check can be dropped once 2.11 is not supported anymore. + # Cf: https://docs.wagtail.io/en/stable/releases/2.12.html#stream-data-on-streamfield-values-is-deprecated + elif hasattr(obj, "stream_data"): + rtn_obj = [] + for field in obj.stream_data: + rtn_obj.append(serialize_struct_obj(field["value"])) + else: + for field in obj: + value = obj[field] + if hasattr(value, "raw_data"): + rtn_obj[field] = [serialize_struct_obj(data.value) for data in value[0]] + elif hasattr(obj, "stream_data"): + rtn_obj[field] = [ + serialize_struct_obj(data["value"]) for data in value.stream_data + ] + elif hasattr(value, "value"): + rtn_obj[field] = value.value + elif hasattr(value, "src"): + rtn_obj[field] = value.src + elif hasattr(value, "file"): + rtn_obj[field] = value.file.url + else: + rtn_obj[field] = value + + return rtn_obj diff --git a/tests/settings_custom_page_interface.py b/tests/settings_custom_page_interface.py index b688c247..319403fb 100644 --- a/tests/settings_custom_page_interface.py +++ b/tests/settings_custom_page_interface.py @@ -1,4 +1,4 @@ from settings import * # noqa: F403 -GRAPPLE["PAGE_INTERFACE"] = "testapp.interfaces.CustomInterface" # noqa: F405 +GRAPPLE["PAGE_INTERFACE"] = "testapp.interfaces.CustomPageInterface" # noqa: F405 diff --git a/tests/test_interfaces.py b/tests/test_interfaces.py index 1ab7d826..6922e35e 100644 --- a/tests/test_interfaces.py +++ b/tests/test_interfaces.py @@ -5,9 +5,9 @@ from django.test import override_settings, tag from test_grapple import BaseGrappleTestWithIntrospection from testapp.factories import BlogPageFactory, CustomInterfaceBlockFactory -from testapp.interfaces import CustomInterface +from testapp.interfaces import CustomPageInterface -from grapple.types.pages import PageInterface, get_page_interface +from grapple.types.interfaces import PageInterface, get_page_interface @skipIf( @@ -35,9 +35,11 @@ def test_schema_with_default_page_interface(self): results["data"]["__type"]["interfaces"], [{"name": "PageInterface"}] ) - @override_settings(GRAPPLE={"PAGE_INTERFACE": "testapp.interfaces.CustomInterface"}) + @override_settings( + GRAPPLE={"PAGE_INTERFACE": "testapp.interfaces.CustomPageInterface"} + ) def test_get_page_interface_with_custom_page_interface(self): - self.assertIs(get_page_interface(), CustomInterface) + self.assertIs(get_page_interface(), CustomPageInterface) def test_streamfield_block_with_custom_interface(self): query = """ @@ -104,5 +106,5 @@ class CustomPageInterfaceTestCase(BaseGrappleTestWithIntrospection): def test_schema_with_custom_page_interface(self): results = self.introspect_schema_by_type("BlogPage") self.assertListEqual( - results["data"]["__type"]["interfaces"], [{"name": "CustomInterface"}] + results["data"]["__type"]["interfaces"], [{"name": "CustomPageInterface"}] ) diff --git a/tests/testapp/interfaces.py b/tests/testapp/interfaces.py index 327c371a..b473215d 100644 --- a/tests/testapp/interfaces.py +++ b/tests/testapp/interfaces.py @@ -1,5 +1,11 @@ import graphene +from grapple.types.interfaces import PageInterface + class CustomInterface(graphene.Interface): custom_text = graphene.String() + + +class CustomPageInterface(PageInterface): + custom_text = graphene.String() diff --git a/tests/testapp/mutations.py b/tests/testapp/mutations.py index 48cfc619..3e8f785a 100644 --- a/tests/testapp/mutations.py +++ b/tests/testapp/mutations.py @@ -3,7 +3,7 @@ from wagtail.models import Page from grapple.registry import registry -from grapple.types.pages import PageInterface +from grapple.types.interfaces import PageInterface from grapple.types.rich_text import RichText from testapp.models import Advert, AuthorPage