diff --git a/baseapp_pages/graphql/object_types.py b/baseapp_pages/graphql/object_types.py index 99384251..de379c7e 100644 --- a/baseapp_pages/graphql/object_types.py +++ b/baseapp_pages/graphql/object_types.py @@ -41,39 +41,6 @@ def resolve_metadata(cls, instance, info, **kwargs): raise NotImplementedError -class URLPathNode(DjangoObjectType): - target = graphene.Field(PageInterface) - language = graphene.Field(LanguagesEnum) - - class Meta: - interfaces = (relay.Node,) - model = URLPath - fields = ( - "id", - "pk", - "path", - "language", - "is_active", - "created", - "modified", - "target", - ) - filter_fields = { - "id": ["exact"], - } - - def resolve_target(self, info, **kwargs): - if isinstance(self.target, AbstractPage): - if not info.context.user.has_perm(f"{page_app_label}.view_page", self.target): - return None - return self.target - - @classmethod - def get_queryset(cls, queryset, info): - MAX_COMPLEXITY = 3 - return optimize(queryset, info, max_complexity=MAX_COMPLEXITY) - - class PageFilter(django_filters.FilterSet): class Meta: model = Page @@ -162,3 +129,36 @@ def is_type_of(cls, root, info): if isinstance(root, AbstractMetadataObjectType): return True return super().is_type_of(root, info) + + +class URLPathNode(DjangoObjectType): + target = graphene.Field(PageInterface) + language = graphene.Field(LanguagesEnum) + + class Meta: + interfaces = (relay.Node,) + model = URLPath + fields = ( + "id", + "pk", + "path", + "language", + "is_active", + "created", + "modified", + "target", + ) + filter_fields = { + "id": ["exact"], + } + + def resolve_target(self, info, **kwargs): + if isinstance(self.target, AbstractPage): + if not info.context.user.has_perm(f"{page_app_label}.view_page", self.target): + return None + return self.target + + @classmethod + def get_queryset(cls, queryset, info): + MAX_COMPLEXITY = 3 + return optimize(queryset, info, max_complexity=MAX_COMPLEXITY) diff --git a/baseapp_pages/graphql/queries.py b/baseapp_pages/graphql/queries.py index fc47d771..c17143ea 100644 --- a/baseapp_pages/graphql/queries.py +++ b/baseapp_pages/graphql/queries.py @@ -37,5 +37,7 @@ def resolve_url_path(self, info, path): ).first() if active_url_path: url_path = active_url_path + else: + url_path = None return url_path diff --git a/baseapp_pages/models.py b/baseapp_pages/models.py index 8079612a..61ae9d3f 100644 --- a/baseapp_pages/models.py +++ b/baseapp_pages/models.py @@ -1,6 +1,9 @@ +from typing import Optional, Type + import pghistory import swapper from django.conf import settings +from django.contrib.auth.models import AbstractUser from django.contrib.contenttypes.fields import GenericForeignKey, GenericRelation from django.contrib.contenttypes.models import ContentType from django.db import models @@ -14,6 +17,7 @@ from baseapp_comments.models import CommentableModel from baseapp_core.graphql.models import RelayModel from baseapp_core.models import random_name_in +from baseapp_pages.utils.url_path_formatter import URLPathFormatter class URLPath(TimeStampedModel, RelayModel): @@ -104,6 +108,39 @@ def url_path(self): Q(is_active=True), Q(language=get_language()) | Q(language__isnull=True) ).first() + def get_graphql_object_type(self) -> Type: + raise NotImplementedError + + def get_permission_check(self, user: AbstractUser) -> bool: + return True + + def create_url_path(self, path: str, language: Optional[str] = None, is_active: bool = True): + if not self.pk: + raise ValueError("Save the instance before creating URL paths.") + return self.url_paths.create( + path=URLPathFormatter(path)(), language=language, is_active=is_active + ) + + def update_url_path(self, path: str, language: Optional[str] = None, is_active: bool = True): + if not self.pk: + raise ValueError("Save the instance before updating URL paths.") + primary_path = self.url_path or self.url_paths.first() + if primary_path: + primary_path.path = URLPathFormatter(path)() + primary_path.language = language + primary_path.is_active = is_active + primary_path.save() + else: + self.create_url_path(path, language, is_active) + + def deactivate_url_paths(self): + if self.pk: + self.url_paths.update(is_active=False) + + def delete_url_paths(self): + if self.pk: + self.url_paths.all().delete() + class AbstractPage(PageMixin, TimeStampedModel, RelayModel, CommentableModel): user = models.ForeignKey( diff --git a/baseapp_pages/tests/test_model_page_mixin.py b/baseapp_pages/tests/test_model_page_mixin.py new file mode 100644 index 00000000..df1f4527 --- /dev/null +++ b/baseapp_pages/tests/test_model_page_mixin.py @@ -0,0 +1,89 @@ +import pytest +import swapper +from django.contrib.auth import get_user_model + +from baseapp_pages.models import URLPath +from baseapp_pages.utils.url_path_formatter import URLPathFormatter + +from .factories import PageFactory + +pytestmark = pytest.mark.django_db + +Page = swapper.load_model("baseapp_pages", "Page") +User = get_user_model() + + +def make_page_mixin_instance(): + return PageFactory() + + +def test_url_path_property_returns_active_path(monkeypatch): + page = make_page_mixin_instance() + url_path_active = page.create_url_path("/test-path", language="en", is_active=True) + page.create_url_path("/test-path-inactive", language="en", is_active=False) + + assert page.url_path == url_path_active + + +def test_url_path_property_returns_none_if_no_active(): + page = make_page_mixin_instance() + # No url paths yet + assert page.url_path is None + # Add only inactive + page.create_url_path("/inactive", language="en", is_active=False) + assert page.url_path is None + + +def test_create_url_path_requires_saved_instance(): + page = Page() # not saved + with pytest.raises(ValueError): + page.create_url_path("/should-fail") + + +def test_create_url_path_creates_urlpath(): + page = make_page_mixin_instance() + url_path = page.create_url_path("/created", language="en", is_active=True) + assert isinstance(url_path, URLPath) + assert url_path.path == URLPathFormatter("/created")() + assert url_path.language == "en" + assert url_path.is_active is True + assert url_path.target == page + + +def test_update_url_path_updates_existing(monkeypatch): + page = make_page_mixin_instance() + url_path = page.create_url_path("/old", language="en", is_active=True) + page.update_url_path("/new", language="fr", is_active=False) + url_path.refresh_from_db() + assert url_path.path == URLPathFormatter("/new")() + assert url_path.language == "fr" + assert url_path.is_active is False + + +def test_update_url_path_creates_if_none_exists(): + page = make_page_mixin_instance() + assert page.url_path is None + page.update_url_path("/created", language="en", is_active=True) + url_path = page.url_path + assert url_path is not None + assert url_path.path == URLPathFormatter("/created")() + assert url_path.language == "en" + assert url_path.is_active is True + + +def test_deactivate_url_paths_sets_all_inactive(): + page = make_page_mixin_instance() + page.create_url_path("/a", language="en", is_active=True) + page.create_url_path("/b", language="fr", is_active=True) + page.deactivate_url_paths() + for url_path in page.url_paths.all(): + assert url_path.is_active is False + + +def test_delete_url_paths_deletes_all(): + page = make_page_mixin_instance() + page.create_url_path("/a", language="en", is_active=True) + page.create_url_path("/b", language="fr", is_active=True) + assert page.url_paths.count() == 2 + page.delete_url_paths() + assert page.url_paths.count() == 0 diff --git a/baseapp_pages/tests/test_utils_url_path_formatter.py b/baseapp_pages/tests/test_utils_url_path_formatter.py new file mode 100644 index 00000000..dde5202e --- /dev/null +++ b/baseapp_pages/tests/test_utils_url_path_formatter.py @@ -0,0 +1,46 @@ +import pytest + +from baseapp_pages.utils.url_path_formatter import URLPathFormatter + + +@pytest.mark.parametrize( + "input_path,expected", + [ + ("/", "/"), + (" / ", "/"), + ("test", "/test"), + ("/test", "/test"), + ("//test", "/test"), + ("/test/", "/test"), + ("//test//foo//bar//", "/test/foo/bar"), + ("foo/bar", "/foo/bar"), + ("/foo/bar", "/foo/bar"), + ("foo/bar/", "/foo/bar"), + ("foo//bar", "/foo/bar"), + ("//foo//bar//", "/foo/bar"), + ("//foo//bar//baz//", "/foo/bar/baz"), + (" /foo/bar/ ", "/foo/bar"), + ], +) +def test_url_path_formatter_valid(input_path, expected): + assert URLPathFormatter(input_path)() == expected + + +@pytest.mark.parametrize( + "bad_path,error_msg", + [ + ("", "Path cannot be empty."), + (" ", "Path cannot be empty."), + ], +) +def test_url_path_formatter_raises_on_empty(bad_path, error_msg): + with pytest.raises(ValueError) as exc: + URLPathFormatter(bad_path)() + assert error_msg in str(exc.value) + + +def test_url_path_formatter_invalid_no_leading_slash_after_format(): + f = URLPathFormatter("foo") + f.path = "foo" # forcibly break invariant + with pytest.raises(ValueError): + f._validate_path() diff --git a/baseapp_pages/utils/__init__.py b/baseapp_pages/utils/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/baseapp_pages/utils/url_path_formatter.py b/baseapp_pages/utils/url_path_formatter.py new file mode 100644 index 00000000..27054aec --- /dev/null +++ b/baseapp_pages/utils/url_path_formatter.py @@ -0,0 +1,53 @@ +import re + + +class URLPathFormatter: + """ + Ensures the path is valid: + - Always starts with a single slash + - Never ends with a slash (unless it's just '/') + - Never has double slashes + - Is at least '/' + Raises ValueError if the format is not compatible. + """ + + path: str + + def __init__(self, path: str): + self.path = path + + def __call__(self) -> str: + return self.format() + + def format(self) -> str: + self._clean_path() + self._replace_multiple_slashes() + self._ensure_starts_with_slash() + self._remove_trailing_slash() + self._validate_path() + + return self.path + + def _clean_path(self) -> str: + self.path = self.path.strip() + if not self.path: + raise ValueError("Path cannot be empty.") + + def _replace_multiple_slashes(self) -> str: + self.path = re.sub(r"/+", "/", self.path) + + def _ensure_starts_with_slash(self) -> str: + if not self.path.startswith("/"): + self.path = "/" + self.path + + def _remove_trailing_slash(self) -> str: + if len(self.path) > 1 and self.path.endswith("/"): + self.path = self.path[:-1] + + def _validate_path(self) -> str: + if not self.path.startswith("/"): + raise ValueError("Path must start with a slash ('/').") + if "//" in self.path: + raise ValueError("Path must not contain double slashes ('//').") + if len(self.path) > 1 and self.path.endswith("/"): + raise ValueError("Path must not end with a slash unless it is root ('/').") diff --git a/baseapp_wagtail/base/apps.py b/baseapp_wagtail/base/apps.py index b104f017..6da2d2d3 100644 --- a/baseapp_wagtail/base/apps.py +++ b/baseapp_wagtail/base/apps.py @@ -5,3 +5,6 @@ class WagtailConfig(AppConfig): name = "baseapp_wagtail.base" verbose_name = "BaseApp Wagtail - Base" label = "baseapp_wagtail_base" + + def ready(self): + import baseapp_wagtail.base.signals # noqa: F401 diff --git a/baseapp_wagtail/base/blocks/basic_blocks/custom_image_block/block.py b/baseapp_wagtail/base/blocks/basic_blocks/custom_image_block/block.py index 41152a96..9934ae45 100644 --- a/baseapp_wagtail/base/blocks/basic_blocks/custom_image_block/block.py +++ b/baseapp_wagtail/base/blocks/basic_blocks/custom_image_block/block.py @@ -6,6 +6,9 @@ @register_streamfield_block class CustomImageBlock(StructBlock): + # TODO (wagtail) Relay.Node creates the id based on the page model (e.g. StandardPage-{img_id}). + # This is a problem when the page has the same id as the image, causing two identical node ids for two different objects. + # The idea of this TODO is to try to fix this behavior from the baseapp_wagtail side. graphql_fields = [ GraphQLImage("image"), GraphQLString("alt_text"), diff --git a/baseapp_wagtail/base/graphql/__init__.py b/baseapp_wagtail/base/graphql/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/baseapp_wagtail/base/graphql/interfaces.py b/baseapp_wagtail/base/graphql/interfaces.py new file mode 100644 index 00000000..b5a51048 --- /dev/null +++ b/baseapp_wagtail/base/graphql/interfaces.py @@ -0,0 +1,47 @@ +import graphene +from django.apps import apps +from grapple.types.interfaces import PageInterface + +from baseapp_core.graphql.models import RelayModel + +""" +Baseapp Interfaces Compatibility + +Wagtail page type IDs are set at runtime, so the graphene ID field is not required by default. This +conflicts with relay.Node, which expects an ID to always be present. Since multiple baseapp +interfaces extend relay.Node, this can cause issues. + +To fix this, we extend the original interfaces and explicitly resolve the ID field, as shown in +WagtailCommentsInterface. Because Wagtail page IDs always exist, this approach ensures compatibility +with relay.Node and avoids ID-related problems. + +However, this approach only works when querying the page directly. When the Wagtail page is +referenced dynamically—such as via content_type and target id (e.g., as a comment target) — the +original interfaces must also be included in WagtailPageObjectType. This ensures the connection and +ID resolution work correctly in these dynamic GraphQL structures. +""" + +if apps.is_installed("baseapp_comments"): + from baseapp_comments.graphql.object_types import CommentsInterface + + class WagtailCommentsInterface(CommentsInterface): + """ + Wagtail-specific comments interface for Wagtail page types that do not support + relay.Node. + """ + + id = graphene.ID() + + def resolve_id(self, info, **kwargs): + if isinstance(self, RelayModel): + return self.id + raise ValueError("WagtailCommentsInterface can only be used with RelayModel instances.") + + +class WagtailPageInterface(PageInterface): + """ + Wagtail-specific page interface that extends Grapple's PageInterface to avoid + conflicts with the baseapp_pages.graphql.PageInterface. + """ + + pass diff --git a/baseapp_wagtail/base/graphql/object_types.py b/baseapp_wagtail/base/graphql/object_types.py index 3bb94058..d6849485 100644 --- a/baseapp_wagtail/base/graphql/object_types.py +++ b/baseapp_wagtail/base/graphql/object_types.py @@ -1,46 +1,69 @@ import graphene -from grapple.types.interfaces import PageInterface - -from baseapp_comments.graphql.object_types import CommentsInterface -from baseapp_notifications.graphql.object_types import NotificationsInterface -from baseapp_reactions.graphql.object_types import ReactionsInterface -from baseapp_reports.graphql.object_types import ReportsInterface - -# TODO: Fix in next story -# https://app.approvd.io/silverlogic/BA/stories/36399 -# As per @ap, these interfaces shouldn't be necessary. They are currently necessary because -# the baseapp interfaces classes inherit from RelayNode, but they shouldn't! -# -# Also if we return -1 for resolve_id, it means we are not Relay compliant. This can cause bugs. - - -class WagtailCommentsInterface(CommentsInterface): - id = graphene.ID() - - def resolve_id(self, info, **kwargs): - return str(self.id) if self.id is not None else -1 - - -class WagtailReactionsInterface(ReactionsInterface): - id = graphene.ID() - - def resolve_id(self, info, **kwargs): - return str(self.id) if self.id is not None else -1 - - -class WagtailNotificationsInterfaceInterface(NotificationsInterface): - id = graphene.ID() - - def resolve_id(self, info, **kwargs): - return str(self.id) if self.id is not None else -1 - - -class WagtailReportsInterfaceInterface(ReportsInterface): - id = graphene.ID() - - def resolve_id(self, info, **kwargs): - return str(self.id) if self.id is not None else -1 - - -class WagtailPageInterface(PageInterface): - pass +from django.apps import apps +from graphene import relay +from graphene_django import DjangoObjectType +from grapple.registry import registry + +from baseapp_wagtail.base.graphql.interfaces import WagtailPageInterface +from baseapp_wagtail.base.models import DefaultPageModel + +wagtail_url_path_object_type_interfaces = [] + +if apps.is_installed("baseapp_pages"): + from baseapp_pages.graphql import PageInterface + + wagtail_url_path_object_type_interfaces.append(PageInterface) + +if apps.is_installed("baseapp_comments"): + from baseapp_comments.graphql.object_types import CommentsInterface + + wagtail_url_path_object_type_interfaces.append(CommentsInterface) + + +class WagtailPageObjectType(DjangoObjectType): + """ + Object type for connecting Wagtail pages with other baseapp interfaces. Use this when Wagtail + pages must be retrieved via different baseapp interfaces. + If only the Wagtail page interface is needed, use the graphql_interfaces attribute in the + Wagtail page models. Note: You may need to extend the interface and resolve the id field as + shown in WagtailCommentsInterface. + """ + + data = graphene.Field(WagtailPageInterface) + + class Meta: + model = DefaultPageModel + interfaces = ( + relay.Node, + *wagtail_url_path_object_type_interfaces, + ) + name = "WagtailPage" + + @classmethod + def is_type_of(cls, root, info): + if isinstance(root, DefaultPageModel): + return True + return super().is_type_of(root, info) + + def resolve_data(self, info): + return self + + @classmethod + def resolve_metadata(cls, instance, info, **kwargs): + if apps.is_installed("baseapp_pages"): + from baseapp_pages.graphql.object_types import MetadataObjectType + + # TODO: (BA-2635) Complete the metadata for Wagtail pages when implementing the story BA-2635. + return MetadataObjectType( + meta_title=instance.title, + meta_description=None, + meta_og_image=None, + meta_og_type="article", + ) + return None + + +BASEAPP_WAGTAIL_TYPES = [ + *registry.models.values(), + WagtailPageObjectType, +] diff --git a/baseapp_wagtail/base/models.py b/baseapp_wagtail/base/models.py index 66193a45..b5538f8b 100644 --- a/baseapp_wagtail/base/models.py +++ b/baseapp_wagtail/base/models.py @@ -1,6 +1,10 @@ -from urllib.parse import urlparse +from typing import Optional +from urllib.parse import urljoin, urlparse +from django.apps import apps from django.conf import settings +from django.core.exceptions import ValidationError +from django.db.models import Q from django.utils.translation import gettext as _ from grapple.models import GraphQLStreamfield from wagtail.admin.panels import FieldPanel @@ -9,10 +13,7 @@ from wagtail.search import index from wagtail_headless_preview.models import HeadlessPreviewMixin -from baseapp_comments.models import CommentableModel from baseapp_core.graphql.models import RelayModel -from baseapp_reactions.models import ReactableModel -from baseapp_reports.models import ReportableModel from .stream_fields import ( FeaturedImageStreamField, @@ -42,11 +43,31 @@ def _has_no_domain(self, url: str) -> bool: parsed_url = urlparse(url) return not parsed_url.netloc + @classmethod + def get_front_url_path(cls, page) -> str: + url_parts = page.get_url_parts() + if not url_parts: + return None + _, _, page_path = url_parts + return page_path + class Meta: abstract = True -class DefaultPageModel(HeadlessPageMixin, Page, metaclass=HeadlessPageBase): +default_page_model_inheritances = [] + +if apps.is_installed("baseapp_pages"): + from baseapp_pages.models import PageMixin + + default_page_model_inheritances.append(PageMixin) + +default_page_model_inheritances.append(RelayModel) + + +class DefaultPageModel( + HeadlessPageMixin, Page, *default_page_model_inheritances, metaclass=HeadlessPageBase +): featured_image = FeaturedImageStreamField.create() body = None @@ -65,17 +86,78 @@ class DefaultPageModel(HeadlessPageMixin, Page, metaclass=HeadlessPageBase): index.AutocompleteField("body"), ] - class Meta: - abstract = True - graphql_fields = [ GraphQLStreamfield("featured_image"), ] + graphql_interfaces = [] -class BaseStandardPage( - DefaultPageModel, CommentableModel, ReactableModel, ReportableModel, RelayModel -): + @property + def pages_url_path(self): + """ + baseapp_pages.models.PageMixin.url_path alternative. + Defines a new property because wagtail pages already have a defined "url_path" property. + """ + return self.url_paths.filter( + Q(is_active=True), Q(language=self.locale.language_code) | Q(language__isnull=True) + ).first() + + def update_url_path(self, path: str, language: Optional[str] = None, is_active: bool = True): + """ + Overrides the baseapp_pages.models.PageMixin.update_url_path method. + This is necessary in order to use the new "pages_url_path" property. + """ + from baseapp_pages.utils.url_path_formatter import URLPathFormatter + + primary_path = self.pages_url_path or self.url_paths.first() + if primary_path: + primary_path.path = URLPathFormatter(path)() + primary_path.language = language + primary_path.is_active = is_active + primary_path.save() + else: + self.create_url_path(path, language, is_active) + + def clean(self): + super().clean() + self._check_urlpath_is_unique() + + def _check_urlpath_is_unique(self): + from baseapp_wagtail.base.urlpath.urlpath_sync import WagtailURLPathSync + + parent_path = self.get_front_url_path(self.get_parent()) if self.get_parent() else "/" + path = urljoin(parent_path, self.slug) + + if WagtailURLPathSync(self).urlpath_exists(path): + raise ValidationError( + { + "slug": _( + "The url path generated from the slug is already in use by another page. Please try a different slug." + ) + } + ) + + class Meta: + abstract = True + + +base_standard_page_model_inheritances = [] + +if apps.is_installed("baseapp_comments"): + from baseapp_comments.models import CommentableModel + + base_standard_page_model_inheritances.append(CommentableModel) + + +base_standard_page_model_graphql_interfaces = [] + +if apps.is_installed("baseapp_comments"): + base_standard_page_model_graphql_interfaces.append( + "baseapp_wagtail.base.graphql.interfaces.WagtailCommentsInterface", + ) + + +class BaseStandardPage(DefaultPageModel, *base_standard_page_model_inheritances): body = PageBodyStreamField.create( StandardPageStreamBlock(required=False), ) @@ -91,8 +173,5 @@ class Meta: ] graphql_interfaces = [ - "baseapp_wagtail.base.graphql.object_types.WagtailCommentsInterface", - "baseapp_wagtail.base.graphql.object_types.WagtailReactionsInterface", - "baseapp_wagtail.base.graphql.object_types.WagtailNotificationsInterfaceInterface", - "baseapp_wagtail.base.graphql.object_types.WagtailReportsInterfaceInterface", + *base_standard_page_model_graphql_interfaces, ] diff --git a/baseapp_wagtail/base/signals.py b/baseapp_wagtail/base/signals.py new file mode 100644 index 00000000..50d8140f --- /dev/null +++ b/baseapp_wagtail/base/signals.py @@ -0,0 +1,22 @@ +from django.dispatch import receiver +from wagtail.signals import page_published, page_unpublished, post_page_move + +from baseapp_wagtail.base.urlpath.urlpath_sync import WagtailURLPathSync + + +@receiver(page_published) +def update_urlpath_on_publish(sender, instance, revision, **kwargs): + if instance.scheduled_revision: + WagtailURLPathSync(instance).create_or_update_urlpath_draft() + else: + WagtailURLPathSync(instance).publish_urlpath() + + +@receiver(post_page_move) +def update_urlpath_on_move(sender, instance, **kwargs): + WagtailURLPathSync(instance.specific).update_urlpath() + + +@receiver(page_unpublished) +def deactivate_urlpath_on_unpublish(sender, instance, **kwargs): + WagtailURLPathSync(instance).deactivate_urlpath() diff --git a/baseapp_wagtail/base/tests/__init__.py b/baseapp_wagtail/base/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/baseapp_wagtail/base/tests/test_graphql_comments_integration.py b/baseapp_wagtail/base/tests/test_graphql_comments_integration.py new file mode 100644 index 00000000..4b4c9f4f --- /dev/null +++ b/baseapp_wagtail/base/tests/test_graphql_comments_integration.py @@ -0,0 +1,288 @@ +from rest_framework import status + +from baseapp_comments.tests.factories import CommentFactory +from baseapp_wagtail.tests.mixins import TestPageContextMixin +from baseapp_wagtail.tests.utils.graphql_helpers import GraphqlHelper +from testproject.base.models import StandardPage + + +class WagtailCommentsIntegrationTests(GraphqlHelper, TestPageContextMixin): + page_model = StandardPage + + def setUp(self): + super().setUp() + self.page.save_revision().publish() + + def test_wagtail_page_has_comments_interface(self): + response = self.query( + """ + query Page($id: ID!) { + page(id: $id) { + id + title + ... on StandardPage { + commentsCount { + total + main + replies + pinned + reported + } + isCommentsEnabled + } + } + } + """, + variables={"id": self.page.id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + data = content["data"]["page"] + self.assertIsNotNone(data["commentsCount"]) + self.assertEqual(data["commentsCount"]["total"], 0) + self.assertEqual(data["commentsCount"]["main"], 0) + self.assertEqual(data["commentsCount"]["replies"], 0) + self.assertEqual(data["commentsCount"]["pinned"], 0) + self.assertEqual(data["commentsCount"]["reported"], 0) + self.assertTrue(data["isCommentsEnabled"]) + + def test_create_comment_on_wagtail_page(self): + response = self.query( + """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + comment { + node { + id + body + target { + id + commentsCount { + total + replies + } + } + } + } + errors { + field + messages + } + } + } + """, + variables={ + "input": { + "targetObjectId": self.page.relay_id, + "body": "This is a test comment on a Wagtail page", + } + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNone(content["data"]["commentCreate"]["errors"]) + + comment_data = content["data"]["commentCreate"]["comment"]["node"] + self.assertEqual(comment_data["body"], "This is a test comment on a Wagtail page") + + target = comment_data["target"] + self.assertIsNotNone(target["id"], self.page.relay_id) + comments_count = target["commentsCount"] + self.assertEqual(comments_count["total"], 1) + self.assertEqual(comments_count["replies"], 0) + + def test_create_reply_comment_on_wagtail_page(self): + parent_comment = CommentFactory(target=self.page) + + response = self.query( + """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + comment { + node { + id + body + inReplyTo { + id + body + } + target { + ... on WagtailPage { + id + commentsCount { + total + main + replies + } + } + } + } + } + errors { + field + messages + } + } + } + """, + variables={ + "input": { + "targetObjectId": self.page.relay_id, + "body": "This is a reply to the parent comment", + "inReplyToId": parent_comment.relay_id, + } + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNone(content["data"]["commentCreate"]["errors"]) + + comment_data = content["data"]["commentCreate"]["comment"]["node"] + self.assertEqual(comment_data["body"], "This is a reply to the parent comment") + + in_reply_to = comment_data["inReplyTo"] + self.assertEqual(in_reply_to["id"], parent_comment.relay_id) + self.assertEqual(in_reply_to["body"], parent_comment.body) + + target = comment_data["target"] + comments_count = target["commentsCount"] + self.assertEqual(comments_count["total"], 2) + self.assertEqual(comments_count["main"], 1) + self.assertEqual(comments_count["replies"], 1) + + def test_query_comments_on_wagtail_page(self): + CommentFactory(target=self.page, body="First comment") + CommentFactory(target=self.page, body="Second comment") + + response = self.query( + """ + query PageComments($id: ID!) { + page(id: $id) { + id + title + ... on StandardPage { + comments { + edges { + node { + id + body + user { + id + } + created + } + } + } + commentsCount { + total + main + } + } + } + } + """, + variables={"id": self.page.id}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + data = content["data"]["page"] + comments = data["comments"]["edges"] + + self.assertEqual(len(comments), 2) + self.assertEqual(data["commentsCount"]["total"], 2) + self.assertEqual(data["commentsCount"]["main"], 2) + + comment_bodies = [edge["node"]["body"] for edge in comments] + self.assertIn("First comment", comment_bodies) + self.assertIn("Second comment", comment_bodies) + + def test_comments_disabled_on_wagtail_page(self): + self.page.is_comments_enabled = False + self.page.save() + + response = self.query( + """ + mutation CommentCreate($input: CommentCreateInput!) { + commentCreate(input: $input) { + comment { + node { + id + body + } + } + errors { + field + messages + } + } + } + """, + variables={ + "input": { + "targetObjectId": self.page.relay_id, + "body": "This comment should not be created", + } + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + errors = content["errors"] + self.assertIsNotNone(errors) + self.assertEqual(errors[0]["extensions"]["code"], "permission_required") + + def test_comments_ordering_and_pagination(self): + CommentFactory(target=self.page, body="Comment 1") + CommentFactory(target=self.page, body="Comment 2") + CommentFactory(target=self.page, body="Comment 3") + + response = self.query( + """ + query PageComments($id: ID!, $first: Int!) { + page(id: $id) { + id + ... on StandardPage { + comments(first: $first) { + edges { + node { + id + body + created + } + cursor + } + pageInfo { + hasNextPage + hasPreviousPage + } + } + commentsCount { + total + } + } + } + } + """, + variables={"id": self.page.id, "first": 2}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + data = content["data"]["page"] + comments = data["comments"]["edges"] + page_info = data["comments"]["pageInfo"] + + self.assertEqual(len(comments), 2) + self.assertTrue(page_info["hasNextPage"]) + self.assertFalse(page_info["hasPreviousPage"]) + self.assertEqual(data["commentsCount"]["total"], 3) diff --git a/baseapp_wagtail/base/tests/test_graphql_urlpath_query.py b/baseapp_wagtail/base/tests/test_graphql_urlpath_query.py new file mode 100644 index 00000000..51dc322d --- /dev/null +++ b/baseapp_wagtail/base/tests/test_graphql_urlpath_query.py @@ -0,0 +1,256 @@ +from rest_framework import status +from wagtail.models import Locale + +from baseapp_wagtail.tests.mixins import TestPageContextMixin +from baseapp_wagtail.tests.utils.graphql_helpers import GraphqlHelper +from testproject.base.models import StandardPage + + +class WagtailURLPathQueryTests(GraphqlHelper, TestPageContextMixin): + def setUp(self): + super().setUp() + self.page.save_revision().publish() + self.url_path = self.page.pages_url_path + + def test_urlpath_query_returns_wagtail_page_data(self): + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + path + target { + __typename + ... on WagtailPage { + data { + id + title + ... on PageForTests { + body { + ... on RichTextBlock { + value + } + } + } + } + } + } + } + } + """, + variables={"path": self.url_path.path}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNotNone(content["data"]["urlPath"]) + self.assertEqual(content["data"]["urlPath"]["path"], self.url_path.path) + + target = content["data"]["urlPath"]["target"] + self.assertEqual(target["__typename"], "WagtailPage") + + data = target["data"] + self.assertEqual(data["id"], str(self.page.id)) + self.assertEqual(data["title"], self.page.title) + + def test_urlpath_query_with_metadata(self): + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + target { + ... on WagtailPage { + data { + title + } + metadata { + metaTitle + metaDescription + metaOgType + } + } + } + } + } + """, + variables={"path": self.url_path.path}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + data = content["data"]["urlPath"]["target"] + metadata = data["metadata"] + self.assertIsNotNone(metadata) + self.assertEqual(metadata["metaTitle"], self.page.title) + self.assertEqual(metadata["metaOgType"], "article") + + def test_urlpath_query_with_standard_page(self): + standard_page = StandardPage( + title="Standard Test Page", + slug="standard-test-page", + path=f"{self.site.root_page.path}0002", + depth=self.site.root_page.depth + 1, + ) + self.site.root_page.add_child(instance=standard_page) + standard_page.save_revision().publish() + standard_url_path = standard_page.pages_url_path + + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + path + target { + __typename + ... on WagtailPage { + data { + id + title + ... on StandardPage { + path + } + } + } + } + } + } + """, + variables={"path": standard_url_path.path}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNotNone(content["data"]["urlPath"]) + target = content["data"]["urlPath"]["target"] + self.assertEqual(target["__typename"], "WagtailPage") + + data = target["data"] + self.assertEqual(data["id"], str(standard_page.id)) + self.assertEqual(data["title"], standard_page.title) + + def test_urlpath_query_with_comments_interface(self): + standard_page = StandardPage( + title="Standard Test Page", + slug="standard-test-page", + path=f"{self.site.root_page.path}0002", + depth=self.site.root_page.depth + 1, + ) + self.site.root_page.add_child(instance=standard_page) + standard_page.save_revision().publish() + standard_url_path = standard_page.pages_url_path + + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + target { + ... on WagtailPage { + data { + ... on StandardPage { + commentsCount { + total + } + } + } + } + } + } + } + """, + variables={"path": standard_url_path.path}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.json()["data"]["urlPath"]["target"]["data"]["commentsCount"]["total"], 0 + ) + + def test_urlpath_query_inactive_path(self): + self.page.update_url_path(path="/inactive-page", language="en", is_active=False) + + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + path + target { + ... on WagtailPage { + data { + id + title + } + } + } + } + } + """, + variables={"path": "/inactive-page"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIsNone(response.json()["data"]["urlPath"]) + + def test_urlpath_query_nonexistent_path(self): + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + path + target { + ... on WagtailPage { + data { + id + title + } + } + } + } + } + """, + variables={"path": "/nonexistent-page"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNone(content["data"]["urlPath"]) + + def test_urlpath_query_with_language_specific_path(self): + standard_page = StandardPage( + title="Standard Test Page", + slug="standard-test-page", + path=f"{self.site.root_page.path}0002", + depth=self.site.root_page.depth + 1, + locale=Locale.objects.get_or_create(language_code="pt")[0], + ) + self.site.root_page.add_child(instance=standard_page) + standard_page.save_revision().publish() + pt_url_path = standard_page.pages_url_path + + response = self.query( + """ + query Page($path: String!) { + urlPath(path: $path) { + path + target { + ... on WagtailPage { + data { + id + title + } + } + } + } + } + """, + variables={"path": pt_url_path.path}, + headers={"HTTP_ACCEPT_LANGUAGE": "pt"}, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + content = response.json() + + self.assertIsNotNone(content["data"]["urlPath"]) + self.assertEqual(content["data"]["urlPath"]["path"], pt_url_path.path) diff --git a/baseapp_wagtail/base/tests/test_urlpath_page_models.py b/baseapp_wagtail/base/tests/test_urlpath_page_models.py new file mode 100644 index 00000000..7565844b --- /dev/null +++ b/baseapp_wagtail/base/tests/test_urlpath_page_models.py @@ -0,0 +1,21 @@ +from django.core.exceptions import ValidationError + +from baseapp_pages.tests.factories import URLPathFactory +from baseapp_wagtail.tests.mixins import TestPageContextMixin + + +class URLPathPageModelsTests(TestPageContextMixin): + def test_urlpath_slug_validation_with_existing_urlpath(self): + URLPathFactory(path="/mypage") + self.page.slug = "mypage" + with self.assertRaises(ValidationError): + self.page.save() + + def test_urlpath_slug_validation_with_existing_urlpath_for_same_page(self): + URLPathFactory(path="/mypage", target=self.page) + self.page.slug = "mypage" + self.page.save() + + def test_urlpath_slug_validation_without_existing_urlpath(self): + self.page.slug = "mypage" + self.page.save() diff --git a/baseapp_wagtail/base/tests/test_urlpath_sync.py b/baseapp_wagtail/base/tests/test_urlpath_sync.py new file mode 100644 index 00000000..4d11383e --- /dev/null +++ b/baseapp_wagtail/base/tests/test_urlpath_sync.py @@ -0,0 +1,157 @@ +from baseapp_pages.tests.factories import URLPathFactory +from baseapp_wagtail.base.urlpath.urlpath_sync import ( + SlugAlreadyTakenError, + WagtailURLPathSync, +) +from baseapp_wagtail.tests.mixins import TestPageContextMixin + + +class TestWagtailURLPathSyncIntegration(TestPageContextMixin): + def test_create_or_update_urlpath_draft_creates_inactive_urlpath(self): + self.page.live = False + self.page.save() + sync = WagtailURLPathSync(self.page) + sync.create_or_update_urlpath_draft() + + urlpath = self.page.url_paths.filter(is_active=False).first() + self.assertIsNotNone(urlpath) + self.assertFalse(urlpath.is_active) + self.assertEqual(urlpath.target, self.page) + + def test_create_or_update_urlpath_draft_raises_error_if_path_taken(self): + other_page = self.page_model( + title="Other Page", + slug="otherpage", + depth=self.site.root_page.depth + 1, + path=f"{self.site.root_page.path}0002", + ) + self.site.root_page.add_child(instance=other_page) + + URLPathFactory(path="/mypage", target=other_page, is_active=False) + + sync = WagtailURLPathSync(self.page) + with self.assertRaises(SlugAlreadyTakenError): + sync.create_or_update_urlpath_draft() + + def test_create_or_update_urlpath_draft_updates_existing_draft(self): + sync = WagtailURLPathSync(self.page) + + sync.create_or_update_urlpath_draft() + initial_urlpath = self.page.url_paths.filter(is_active=False).first() + initial_path = initial_urlpath.path + + # Change page slug and create draft again + self.page.slug = "newslug" + self.page.save() + sync.create_or_update_urlpath_draft() + + updated_urlpath = self.page.url_paths.filter(is_active=False).first() + self.assertEqual(updated_urlpath.id, initial_urlpath.id) + self.assertNotEqual(updated_urlpath.path, initial_path) + + def test_publish_urlpath_sets_active(self): + sync = WagtailURLPathSync(self.page) + + sync.create_or_update_urlpath_draft() + self.assertFalse(self.page.url_paths.filter(is_active=True).exists()) + + sync.publish_urlpath() + + urlpath = self.page.url_paths.filter(is_active=True).first() + self.assertIsNotNone(urlpath) + self.assertTrue(urlpath.is_active) + + def test_publish_urlpath_deletes_old_active_paths(self): + sync = WagtailURLPathSync(self.page) + + sync.create_or_update_urlpath_draft() + sync.publish_urlpath() + initial_active = self.page.url_paths.filter(is_active=True).first() + + self.page.slug = "newslug" + self.page.save() + sync.create_or_update_urlpath_draft() + sync.publish_urlpath() + + self.assertFalse(self.page.url_paths.filter(id=initial_active.id, is_active=True).exists()) + + new_active = self.page.url_paths.filter(is_active=True).first() + self.assertIsNotNone(new_active) + self.assertNotEqual(new_active.id, initial_active.id) + + def test_deactivate_urlpath_sets_inactive(self): + sync = WagtailURLPathSync(self.page) + + sync.create_or_update_urlpath_draft() + sync.publish_urlpath() + self.assertTrue(self.page.url_paths.filter(is_active=True).exists()) + + sync.deactivate_urlpath() + + self.assertFalse(self.page.url_paths.filter(is_active=True).exists()) + + def test_delete_urlpath_removes_urlpath(self): + sync = WagtailURLPathSync(self.page) + + sync.create_or_update_urlpath_draft() + self.assertTrue(self.page.url_paths.exists()) + + sync.delete_urlpath() + + self.assertFalse(self.page.url_paths.exists()) + + def test_urlpath_exists_returns_true_if_path_taken(self): + sync = WagtailURLPathSync(self.page) + + other_page = self.page_model( + title="Other Page", + slug="otherpage", + depth=self.site.root_page.depth + 1, + path=f"{self.site.root_page.path}0002", + ) + self.site.root_page.add_child(instance=other_page) + URLPathFactory(path="/mypage", target=other_page, is_active=False) + + self.assertTrue(sync.urlpath_exists("/mypage")) + + def test_urlpath_exists_returns_false_if_path_not_taken(self): + sync = WagtailURLPathSync(self.page) + + self.assertFalse(sync.urlpath_exists("/nonexistent")) + + def test_urlpath_exists_returns_false_if_path_taken_by_same_target(self): + sync = WagtailURLPathSync(self.page) + + URLPathFactory(path="/mypage", target=self.page, is_active=False) + + self.assertFalse(sync.urlpath_exists("/mypage")) + + def test_can_sync_true_for_page_mixin(self): + sync = WagtailURLPathSync(self.page) + self.assertTrue(sync._can_sync()) + + def test_can_sync_false_if_not_urlpath_target(self): + from wagtail.models import Page + + root = Page.objects.get(id=self.root_page.id) + plain_page = Page(title="Plain", slug="plain", depth=2, path="00010002") + root.add_child(instance=plain_page) + sync = WagtailURLPathSync(plain_page) + self.assertFalse(sync._can_sync()) + + def test_can_sync_false_if_baseapp_pages_not_installed(self): + sync = WagtailURLPathSync(self.page) + self.assertTrue(sync._can_sync()) + + def test_get_wagtail_path_formats_path_correctly(self): + sync = WagtailURLPathSync(self.page) + wagtail_path = sync._get_wagtail_path() + + self.assertIsNotNone(wagtail_path) + self.assertIn("mypage", wagtail_path) + + def test_path_formatting_uses_urlpath_formatter(self): + sync = WagtailURLPathSync(self.page) + + formatted_path = sync._format_path("/test/path") + self.assertEqual(formatted_path, "/test/path") # URLPathFormatter should format this diff --git a/baseapp_wagtail/base/tests/test_urlpath_sync_hooks.py b/baseapp_wagtail/base/tests/test_urlpath_sync_hooks.py new file mode 100644 index 00000000..d80e6b7f --- /dev/null +++ b/baseapp_wagtail/base/tests/test_urlpath_sync_hooks.py @@ -0,0 +1,75 @@ +from datetime import timedelta + +from django.utils import timezone +from rest_framework import status + +from baseapp_wagtail.tests.mixins import TestAdminActionsMixin + + +class URLPathSyncHooksTests(TestAdminActionsMixin): + def test_scheduled_publish_creates_scheduled_revision(self): + go_live_at = timezone.now() + timedelta(hours=1) + + response = self._post_publish_page( + self.page, + { + "slug": "scheduled-page", + "go_live_at": go_live_at.strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self._reload_the_page() + + self.assertIsNotNone(self.page.scheduled_revision) + self.assertEqual(self.page.slug, "scheduled-page") + + def test_scheduled_publish_hook_integration(self): + go_live_at = timezone.now() + timedelta(hours=1) + + response = self._post_publish_page( + self.page, + { + "slug": "hook-test-page", + "go_live_at": go_live_at.strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + + self._reload_the_page() + + self.assertIsNotNone(self.page.scheduled_revision) + + urlpath_draft = self.page.url_paths.filter(is_active=False).first() + self.assertIsNotNone(urlpath_draft) + self.assertFalse(urlpath_draft.is_active) + + def test_scheduled_publish_hook_handles_multiple_scheduled_pages(self): + go_live_at_1 = timezone.now() + timedelta(hours=1) + go_live_at_2 = timezone.now() + timedelta(hours=2) + + response1 = self._post_publish_page( + self.page, + { + "slug": "scheduled-page-1", + "go_live_at": go_live_at_1.strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + self.assertEqual(response1.status_code, status.HTTP_200_OK) + + response2 = self._post_publish_page( + self.page, + { + "slug": "scheduled-page-2", + "go_live_at": go_live_at_2.strftime("%Y-%m-%d %H:%M:%S"), + }, + ) + self.assertEqual(response2.status_code, status.HTTP_200_OK) + + self._reload_the_page() + + self.assertIsNotNone(self.page.scheduled_revision) + + urlpath_draft = self.page.url_paths.filter(is_active=False).first() + self.assertIsNotNone(urlpath_draft) + self.assertFalse(urlpath_draft.is_active) diff --git a/baseapp_wagtail/base/tests/test_urlpath_sync_signals.py b/baseapp_wagtail/base/tests/test_urlpath_sync_signals.py new file mode 100644 index 00000000..6f13e8e5 --- /dev/null +++ b/baseapp_wagtail/base/tests/test_urlpath_sync_signals.py @@ -0,0 +1,19 @@ +from baseapp_wagtail.base.urlpath.urlpath_sync import WagtailURLPathSync +from baseapp_wagtail.tests.mixins import TestPageContextMixin + + +class URLPathSyncSignalsTests(TestPageContextMixin): + def test_publish_page_creates_urlpath(self): + self.page.save_revision().publish() + self._reload_the_page() + + urlpath = self.page.url_paths.filter(is_active=True).first() + self.assertIsNotNone(urlpath) + self.assertEqual(urlpath.path, "/mypage") + + def test_publish_page_with_existing_urlpath_drafs(self): + WagtailURLPathSync(self.page).create_or_update_urlpath_draft() + self.assertEqual(self.page.url_paths.filter(is_active=False).count(), 1) + + self.page.save_revision().publish() + self.assertEqual(self.page.url_paths.all().count(), 1) diff --git a/baseapp_wagtail/base/urlpath/__init__.py b/baseapp_wagtail/base/urlpath/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/baseapp_wagtail/base/urlpath/urlpath_sync.py b/baseapp_wagtail/base/urlpath/urlpath_sync.py new file mode 100644 index 00000000..79bd4f2d --- /dev/null +++ b/baseapp_wagtail/base/urlpath/urlpath_sync.py @@ -0,0 +1,162 @@ +import logging +from typing import Optional +from urllib.parse import urljoin + +from django.apps import apps +from django.db.models import Model + +from baseapp_wagtail.base.models import DefaultPageModel + +logger = logging.getLogger(__name__) + + +class SlugAlreadyTakenError(Exception): + """Exception raised when a slug/path is already taken by a different target.""" + + pass + + +class WagtailURLPathSync: + page: DefaultPageModel + urlpath_model: Optional[Model] + is_baseapp_pages_installed: bool + + def __init__(self, page: DefaultPageModel): + self.page = page + self.urlpath_model = None + self.is_baseapp_pages_installed = apps.is_installed("baseapp_pages") + self._load_urlpath_model() + + def _load_urlpath_model(self): + if self.is_baseapp_pages_installed: + from baseapp_pages.models import URLPath + + self.urlpath_model = URLPath + else: + self.urlpath_model = None + + def create_or_update_urlpath_draft(self): + if not self._can_sync(): + return + + wagtail_path = self._get_wagtail_path() + if not wagtail_path: + return + + if self._is_path_taken_by_different_target(wagtail_path): + raise SlugAlreadyTakenError(f"Slug '{wagtail_path}' is already taken by another page") + + try: + live_version = self.page.url_paths.filter(is_active=True).first() + if not live_version: + self.page.update_url_path( + path=wagtail_path, language=self.page.locale.language_code, is_active=False + ) + elif live_version.path != wagtail_path: + if url_path := self.page.url_paths.filter(is_active=False).first(): + url_path.path = wagtail_path + url_path.language = self.page.locale.language_code + url_path.is_active = False + url_path.save() + else: + self.page.create_url_path( + path=wagtail_path, language=self.page.locale.language_code, is_active=False + ) + except Exception as e: + logger.error(f"(Wagtail urlpath sync) Error creating urlpath: {e}") + return + + def publish_urlpath(self): + if not self._can_sync(): + return + + wagtail_path = self._get_wagtail_path() + if not wagtail_path: + return + + try: + self.page.url_paths.filter(is_active=True).delete() + self.page.update_url_path( + path=wagtail_path, language=self.page.locale.language_code, is_active=True + ) + except Exception as e: + logger.error(f"(Wagtail urlpath sync) Error publishing urlpath: {e}") + return + + def update_urlpath(self): + if not self._can_sync(): + return + + wagtail_path = self._get_wagtail_path() + if not wagtail_path: + return + + try: + self.page.update_url_path( + path=wagtail_path, language=self.page.locale.language_code, is_active=self.page.live + ) + except Exception as e: + logger.error(f"(Wagtail urlpath sync) Error updating urlpath: {e}") + return + + def deactivate_urlpath(self): + if not self._can_sync(): + return + + try: + self.page.deactivate_url_paths() + except Exception as e: + logger.error(f"(Wagtail urlpath sync) Error deactivating urlpath: {e}") + return + + def delete_urlpath(self): + if not self._can_sync(): + return + + try: + self.page.delete_url_paths() + except Exception as e: + logger.error(f"(Wagtail urlpath sync) Error deleting urlpath: {e}") + return + + def urlpath_exists(self, path: str) -> bool: + if not self._can_sync(): + return False + + path = self._format_path(path) + + return self._is_path_taken_by_different_target(path) + + def _can_sync(self) -> bool: + return ( + self.is_baseapp_pages_installed and self._is_available() and self._is_urlpath_target() + ) + + def _is_available(self) -> bool: + return self.urlpath_model is not None + + def _is_urlpath_target(self) -> bool: + from baseapp_pages.models import PageMixin + + return isinstance(self.page, PageMixin) + + def _is_path_taken_by_different_target(self, path: str) -> bool: + path = self._format_path(path) + + existing_urlpath = self.urlpath_model.objects.filter(path=path).first() + if existing_urlpath and existing_urlpath.target != self.page: + return True + + return False + + def _format_path(self, path: str) -> str: + if self.is_baseapp_pages_installed: + from baseapp_pages.utils.url_path_formatter import URLPathFormatter + + return URLPathFormatter(path)() + return path + + def _get_wagtail_path(self) -> Optional[str]: + parent_path = self.page.get_front_url_path(self.page.get_parent()) + page_path = urljoin(parent_path, self.page.slug) + return self._format_path(page_path) diff --git a/baseapp_wagtail/base/wagtail_hooks.py b/baseapp_wagtail/base/wagtail_hooks.py index 6bfc1375..e572e646 100644 --- a/baseapp_wagtail/base/wagtail_hooks.py +++ b/baseapp_wagtail/base/wagtail_hooks.py @@ -2,12 +2,7 @@ from wagtail import hooks from wagtail.rich_text import LinkHandler - -@hooks.register("register_rich_text_features") -def register_core_features(features): - features.default_features.append("blockquote") - features.register_link_type(ExternalLinkHandler) - features.register_link_type(EmailLinkHandler) +from baseapp_wagtail.base.urlpath.urlpath_sync import WagtailURLPathSync @hooks.register("register_schema_query") @@ -18,6 +13,19 @@ def register_schema_query(query_mixins): query_mixins.remove(query_mixin) +@hooks.register("after_publish_page") +def save_urlpath_draft_on_schedule_publish(request, page): + if page.scheduled_revision: + WagtailURLPathSync(page.scheduled_revision.as_object()).create_or_update_urlpath_draft() + + +@hooks.register("register_rich_text_features") +def register_core_features(features): + features.default_features.append("blockquote") + features.register_link_type(ExternalLinkHandler) + features.register_link_type(EmailLinkHandler) + + class ExternalLinkHandler(LinkHandler): identifier = "external" diff --git a/baseapp_wagtail/settings.py b/baseapp_wagtail/settings.py index dd1dfbd5..f0211fe5 100644 --- a/baseapp_wagtail/settings.py +++ b/baseapp_wagtail/settings.py @@ -56,7 +56,7 @@ if "FRONT_URL" not in globals(): FRONT_URL = env("FRONT_URL", "", required=False) -FRONT_HEADLESS_URL = urljoin(FRONT_URL, env("WAGTAIL_FRONT_URL_PATH", default="/pages")) +FRONT_HEADLESS_URL = urljoin(FRONT_URL, env("WAGTAIL_FRONT_URL_PATH", default="/")) FRONT_PAGE_PREVIEW_URL = urljoin( FRONT_URL, env("WAGTAIL_FRONT_PAGE_PREVIEW_URL_PATH", default="/page-preview") ) @@ -71,7 +71,7 @@ "baseapp_wagtail_base", "baseapp_wagtail_medias", ], - "PAGE_INTERFACE": "baseapp_wagtail.base.graphql.object_types.WagtailPageInterface", + "PAGE_INTERFACE": "baseapp_wagtail.base.graphql.interfaces.WagtailPageInterface", } WAGTAILIMAGES_IMAGE_MODEL = "baseapp_wagtail_medias.CustomImage" diff --git a/baseapp_wagtail/tests/mixins.py b/baseapp_wagtail/tests/mixins.py index 54149fc4..40fbc428 100644 --- a/baseapp_wagtail/tests/mixins.py +++ b/baseapp_wagtail/tests/mixins.py @@ -1,4 +1,5 @@ import urllib.parse +from typing import Type from django.test import TestCase, override_settings from django.urls import reverse @@ -6,7 +7,6 @@ from wagtail.test.utils import WagtailPageTestCase, WagtailTestUtils import baseapp_wagtail.medias.tests.factories as medias_factories -from baseapp_core.tests.factories import UserFactory from baseapp_wagtail.tests.factories.wagtail_factories import LocaleFactory from baseapp_wagtail.tests.models import PageForTests @@ -17,13 +17,16 @@ class WagtailBasicMixin(WagtailPageTestCase, WagtailTestUtils, TestCase): class TestPageContextMixin(WagtailBasicMixin): + page_model: Type[Page] = PageForTests + root_page: PageForTests + page: PageForTests + @classmethod def setUpTestData(cls): - cls.user = UserFactory() root_page = Page.get_first_root_node() if not root_page: LocaleFactory(language_code="en") - root_page = PageForTests( + root_page = cls.page_model( title="Root", slug="root", depth=1, @@ -40,7 +43,7 @@ def setUpTestData(cls): "site_name": "localhost", }, ) - cls.page = PageForTests( + cls.page = cls.page_model( title="My Page", slug="mypage", depth=cls.site.root_page.depth + 1, @@ -48,22 +51,107 @@ def setUpTestData(cls): ) cls.site.root_page.add_child(instance=cls.page) + def setUp(self): + super().setUp() + self.user = self.login() + def _reload_the_page(self): - self.page = PageForTests.objects.get(id=self.page.id) + self.page = self.page_model.objects.get(id=self.page.id) + + +class TestAdminActionsMixin(TestPageContextMixin): + def setUp(self): + super().setUp() + self.image = medias_factories.ImageFactory() + + def _get_page_by_slug(self, slug=None): + slug = slug or self._get_page_slug() + return self.page_model.objects.get(slug=slug) + + def _get_page_slug(self): + return "test-page" def _get_edit_page(self, page): response = self.client.get(reverse("wagtailadmin_pages:edit", args=[page.id])) return response + def _post_new_page(self, extra_data=None): + post_data = self._get_page_data(extra_data) + response = self.client.post( + reverse( + "wagtailadmin_pages:add", + args=("tests", self.page_model.__name__.lower(), self.page.id), + ), + post_data, + follow=True, + ) + return response + + def _post_edit_page(self, page, extra_data=None): + post_data = self._get_page_data(extra_data) + response = self.client.post( + reverse("wagtailadmin_pages:edit", args=[page.id]), + post_data, + follow=True, + ) + return response + + def _post_publish_page(self, page, extra_data=None): + publish_data = { + "action-publish": "action-publish", + } + if extra_data: + publish_data.update(extra_data) + return self._post_edit_page(page, publish_data) + + def _post_unpublish_page(self, page): + response = self.client.post( + reverse("wagtailadmin_pages:unpublish", args=[page.id]), + {}, + follow=True, + ) + return response + + def _post_delete_page(self, page): + response = self.client.post( + reverse("wagtailadmin_pages:delete", args=[page.id]), + {}, + follow=True, + ) + return response + + def _get_page_data(self, extra_data): + post_data = { + "title": "Test page!", + "slug": self._get_page_slug(), + "featured_image-count": "0", + "body": [ + ( + "rich_text_block", + { + "value": "Hello", + }, + ), + ], + "body-count": "1", + "body-0-deleted": "", + "body-0-order": "0", + "body-0-type": "text", + "body-0-value": "hello world", + } + post_data.update(self._get_featured_image_raw_data()) + if extra_data: + post_data.update(extra_data) + return post_data + def _get_featured_image_raw_data(self): - image = medias_factories.ImageFactory() return { "featured_image-count": "1", "featured_image-0-deleted": "", "featured_image-0-order": "0", "featured_image-0-type": "featured_image", "featured_image-0-id": "random-id", - "featured_image-0-value-image": image.id, + "featured_image-0-value-image": self.image.id, "featured_image-0-value-alt_text": "", "featured_image-0-value-attribution": "", "featured_image-0-value-caption": "", diff --git a/setup.cfg b/setup.cfg index 800435ab..5d4d5790 100644 --- a/setup.cfg +++ b/setup.cfg @@ -75,7 +75,7 @@ wagtail = wagtail == 7.0.1 wagtail-headless-preview == 0.8 # TODO: Update to the original repo once the PR is merged (https://github.com/torchbox/wagtail-grapple/pull/423) - wagtail_grapple @ git+https://github.com/Hercilio1/wagtail-grapple@602faa94a1fd216f05daf739e39fd72d9908eb37 + wagtail_grapple @ git+https://github.com/Hercilio1/wagtail-grapple@874e3d3a6b677ee0b579511f83c75f537b0fa5b1 socialauth = hashids == 1.3.1 rest-social-auth >= 8.1.0 diff --git a/testproject/base/migrations/0005_remove_standardpage_is_reactions_enabled_and_more.py b/testproject/base/migrations/0005_remove_standardpage_is_reactions_enabled_and_more.py new file mode 100644 index 00000000..fe641a53 --- /dev/null +++ b/testproject/base/migrations/0005_remove_standardpage_is_reactions_enabled_and_more.py @@ -0,0 +1,25 @@ +# Generated by Django 5.1.11 on 2025-08-14 00:40 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("base", "0004_alter_standardpage_featured_image"), + ] + + operations = [ + migrations.RemoveField( + model_name="standardpage", + name="is_reactions_enabled", + ), + migrations.RemoveField( + model_name="standardpage", + name="reactions_count", + ), + migrations.RemoveField( + model_name="standardpage", + name="reports_count", + ), + ] diff --git a/testproject/graphql.py b/testproject/graphql.py index 70973d57..b0efdb63 100644 --- a/testproject/graphql.py +++ b/testproject/graphql.py @@ -2,7 +2,6 @@ from graphene import relay from graphene.relay.node import NodeField as RelayNodeField from graphene_django.debug import DjangoDebug -from grapple.registry import registry from baseapp.activity_log.graphql.queries import ActivityLogQueries from baseapp.content_feed.graphql.mutations import ContentFeedMutations @@ -31,6 +30,7 @@ from baseapp_reports.graphql.mutations import ReportsMutations from baseapp_reports.graphql.queries import ReportsQueries from baseapp_wagtail.base.graphql.mutations import WagtailMutation +from baseapp_wagtail.base.graphql.object_types import BASEAPP_WAGTAIL_TYPES from baseapp_wagtail.base.graphql.queries import WagtailQuery from baseapp_wagtail.base.graphql.subscriptions import WagtailSubscription from testproject.users.graphql.queries import UsersQueries @@ -84,6 +84,14 @@ class Subscription( pass +schema_types = [ + *BASEAPP_WAGTAIL_TYPES, +] + + schema = graphene.Schema( - query=Query, mutation=Mutation, subscription=Subscription, types=list(registry.models.values()) + query=Query, + mutation=Mutation, + subscription=Subscription, + types=schema_types, ) diff --git a/testproject/settings.py b/testproject/settings.py index af4fd60a..4b66f6cb 100644 --- a/testproject/settings.py +++ b/testproject/settings.py @@ -48,8 +48,8 @@ "testproject.profiles", "testproject.base", "testproject.e2e", - *WAGTAIL_INSTALLED_INTERNAL_APPS, *WAGTAIL_INSTALLED_APPS, + *WAGTAIL_INSTALLED_INTERNAL_APPS, "baseapp_wagtail.tests", "baseapp_pdf", ]