Skip to content

Feature/ba 2656 be setup routing for wagtail and base app pages #313

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 33 additions & 33 deletions baseapp_pages/graphql/object_types.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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)
2 changes: 2 additions & 0 deletions baseapp_pages/graphql/queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
37 changes: 37 additions & 0 deletions baseapp_pages/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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):
Expand Down Expand Up @@ -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(
Expand Down
89 changes: 89 additions & 0 deletions baseapp_pages/tests/test_model_page_mixin.py
Original file line number Diff line number Diff line change
@@ -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
46 changes: 46 additions & 0 deletions baseapp_pages/tests/test_utils_url_path_formatter.py
Original file line number Diff line number Diff line change
@@ -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()
Empty file.
53 changes: 53 additions & 0 deletions baseapp_pages/utils/url_path_formatter.py
Original file line number Diff line number Diff line change
@@ -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 ('/').")
3 changes: 3 additions & 0 deletions baseapp_wagtail/base/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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"),
Expand Down
Empty file.
47 changes: 47 additions & 0 deletions baseapp_wagtail/base/graphql/interfaces.py
Original file line number Diff line number Diff line change
@@ -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
Loading