Skip to content
Merged
Show file tree
Hide file tree
Changes from 7 commits
Commits
Show all changes
26 commits
Select commit Hold shift + click to select a range
db0fd08
feat: Add serializer for menu nodes
fsbraun Aug 29, 2025
9101c43
feat: endpoints and noop-views
fsbraun Aug 29, 2025
e2e4707
feat: Working serializer
fsbraun Aug 29, 2025
c5956d0
Fix: request.current_page
fsbraun Aug 29, 2025
352b2a4
perf: Avoid page query for menu-root
fsbraun Aug 29, 2025
5a2c83f
feat: Rename endpoints
fsbraun Sep 1, 2025
c705a36
feat: Replace preview endpoints by "?preview" GET param
fsbraun Sep 2, 2025
8675f4d
Update djangocms_rest/views.py
fsbraun Sep 2, 2025
37fe8bb
tests: Add first menu test
fsbraun Sep 2, 2025
caf21ff
Improve tests
fsbraun Sep 2, 2025
e1b2358
fix utils test case
fsbraun Sep 2, 2025
4e3b481
Add menu below id, submenu and breadcrumbs
fsbraun Sep 2, 2025
a948e34
Update readme
fsbraun Sep 2, 2025
b375e13
Update tests
fsbraun Sep 2, 2025
d556d8c
fix: No class patching -> better thread-savety
fsbraun Sep 2, 2025
cbe17ca
fix: Pickling error
fsbraun Sep 4, 2025
e3dfc79
add comment
fsbraun Sep 4, 2025
b29b7e7
Merge branch 'main' into fest/menu-endpoint
fsbraun Sep 5, 2025
7a87be1
Update pytest
fsbraun Sep 5, 2025
21aaf7a
Merge branch 'main' into fest/menu-endpoint
fsbraun Sep 15, 2025
c4209d9
Merge branch 'fest/menu-endpoint' of github.com:fsbraun/djangocms-res…
fsbraun Sep 15, 2025
57c6386
fix: Remove id, allow `?preview=false`, fix placeholder preview
fsbraun Sep 15, 2025
f17ee78
tests: Add test for preview param
fsbraun Sep 15, 2025
e8c18d1
fix: Remove id from menu endpoint
fsbraun Sep 15, 2025
98033ee
fix: placeholder preview
fsbraun Sep 15, 2025
687bee9
Merge branch 'main' into fest/menu-endpoint
fsbraun Sep 15, 2025
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
4 changes: 2 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -304,7 +304,7 @@ disallow/limit public access, or at least implement proper caching.
|:----------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| `/api/languages/` | Fetch available languages. |
| `/api/plugins/` | Fetch types for all installed plugins. Used for automatic type checks with frontend frameworks. |
| `/api/{language}/pages-root/` | Fetch the root page for a given language. |
| `/api/{language}/pages/` | Fetch the root page for a given language. |
| `/api/{language}/pages-tree/` | Fetch the complete page tree of all published documents for a given language. Suitable for smaller projects for automatic navigation generation. For large page sets, use the `pages-list` endpoint instead. |
| `/api/{language}/pages-list/` | Fetch a paginated list. Supports `limit` and `offset` parameters for frontend structure building. |
| `/api/{language}/pages/{path}/` | Fetch page details by path for a given language. Path and language information is available via `pages-list` and `pages-tree` endpoints. |
Expand All @@ -319,7 +319,7 @@ To determine permissions `user_can_view_page()` from djangocms is used, usually

| Private Endpoints | Description |
|:-----------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------|
| `/api/preview/{language}/pages-root` | Fetch the latest draft content for the root page. |
| `/api/preview/{language}/pages` | Fetch the latest draft content for the root page. |
| `/api/preview/{language}/pages-tree` | Fetch the page tree including unpublished pages. |
| `/api/preview/{language}/pages-list` | Fetch a paginated list including unpublished pages. |
| `/api/preview/{language}/pages/{path}` | Fetch the latest draft content from a published or unpublished page, including latest unpublished content objects. |
Expand Down
33 changes: 32 additions & 1 deletion djangocms_rest/cms_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@
from django.urls import NoReverseMatch, reverse

from cms.app_base import CMSAppConfig
from cms.models import Page
from cms.cms_menus import CMSMenu
from cms.models import Page, PageContent
from cms.utils.i18n import force_language, get_current_language
from menus.base import NavigationNode


try:
Expand Down Expand Up @@ -42,6 +44,33 @@ def get_file_api_endpoint(file):
return file.url if file.is_public else None


def patch_get_menu_node_for_page_content(method: callable) -> callable:
def inner(self, page_content: PageContent, *args, **kwargs):
node = method(self, page_content, *args, **kwargs)
node.api_endpoint = get_page_api_endpoint(
page_content.page,
page_content.language,
)
return node

return inner


def add_api_endpoint(navigation_node: type[NavigationNode]):
"""Add an API endpoint to the CMSNavigationNode."""
if not hasattr(navigation_node, "get_api_endpoint"):
navigation_node.api_endpoint = None
navigation_node.get_api_endpoint = lambda self: self.api_endpoint


def patch_page_menu(menu: type[CMSMenu]):
"""Patch the CMSMenu to use the REST API endpoint for pages."""
if hasattr(menu, "get_menu_node_for_page_content"):
menu.get_menu_node_for_page_content = patch_get_menu_node_for_page_content(
menu.get_menu_node_for_page_content
)


class RESTToolbarMixin:
"""
Mixin to add REST rendering capabilities to the CMS toolbar.
Expand Down Expand Up @@ -73,3 +102,5 @@ class RESTCMSConfig(CMSAppConfig):

Page.add_to_class("get_api_endpoint", get_page_api_endpoint)
File.add_to_class("get_api_endpoint", get_file_api_endpoint) if File else None
add_api_endpoint(NavigationNode)
patch_page_menu(CMSMenu)
49 changes: 49 additions & 0 deletions djangocms_rest/serializers/menus.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
from rest_framework import serializers

from menus.base import NavigationNode

from djangocms_rest.utils import get_absolute_frontend_url


class NavigationNodeSerializer(serializers.Serializer):
id = serializers.IntegerField()
namespace = serializers.CharField(allow_null=True)
title = serializers.CharField()
url = serializers.URLField(allow_null=True)
api_endpoint = serializers.URLField(allow_null=True)
visible = serializers.BooleanField()
selected = serializers.BooleanField()
attr = serializers.DictField(allow_null=True)
level = serializers.IntegerField(allow_null=True)
children = serializers.SerializerMethodField()

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.request = self.context.get("request")

def get_children(self, obj: NavigationNode) -> list[dict]:
# Assuming obj.children is a list of NavigationNode-like objects
serializer = NavigationNodeSerializer(
obj.children or [], many=True, context=self.context
)
return serializer.data

def to_representation(self, obj: NavigationNode) -> dict:
"""Customize the base representation of the NavigationNode."""
return {
"id": obj.id,
"title": obj.title,
"url": get_absolute_frontend_url(self.request, obj.url),
"api_endpoint": get_absolute_frontend_url(self.request, obj.api_endpoint),
"visible": obj.visible,
"selected": obj.selected
or obj.attr.get("is_home", False)
and getattr(self.request, "is_home", False),
"attr": obj.attr,
"level": obj.level,
"children": self.get_children(obj),
}


class NavigationNodeListSerializer(serializers.ListSerializer):
child = NavigationNodeSerializer()
41 changes: 5 additions & 36 deletions djangocms_rest/serializers/pages.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,11 @@ class BasePageSerializer(serializers.Serializer):
changed_date = serializers.DateTimeField()


class PreviewMixin:
"""Mixin to mark content as preview"""

is_preview = True


class BasePageContentMixin:
@property
def is_preview(self):
return "preview" in self.request.GET

def get_base_representation(self, page_content: PageContent) -> dict:
request = getattr(self, "request", None)
path = page_content.page.get_path(page_content.language)
Expand Down Expand Up @@ -150,7 +148,7 @@ def to_representation(self, page_content: PageContent) -> dict:
]
placeholders = [
placeholder
for placeholder in page_content.page.get_placeholders(page_content.language)
for placeholder in page_content.placeholders.all()
if placeholder.slot in declared_slots
]

Expand All @@ -173,35 +171,6 @@ def to_representation(self, page_content: PageContent) -> dict:
return data


class PreviewPageContentSerializer(PageContentSerializer, PreviewMixin):
"""Serializer specifically for preview/draft page content"""

placeholders = PlaceholderRelationSerializer(many=True, required=False)

def to_representation(self, page_content: PageContent) -> dict:
# Get placeholders directly from the page_content
# This avoids the extra query to get_declared_placeholders
placeholders = page_content.placeholders.all()

placeholders_data = [
{
"content_type_id": placeholder.content_type_id,
"object_id": placeholder.object_id,
"slot": placeholder.slot,
}
for placeholder in placeholders
]

data = self.get_base_representation(page_content)
data["placeholders"] = PlaceholderRelationSerializer(
placeholders_data,
language=page_content.language,
context={"request": self.request},
many=True,
).data
return data


class PageListSerializer(BasePageSerializer, BasePageContentMixin):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
Expand Down
33 changes: 10 additions & 23 deletions djangocms_rest/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
name="page-list",
),
path(
"<slug:language>/pages-root/",
"<slug:language>/pages/",
views.PageDetailView.as_view(),
name="page-root",
),
Expand All @@ -36,30 +36,17 @@
name="placeholder-detail",
),
path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"),
# Preview content endpoints
# Menu endpoints
path("<slug:language>/menu/", views.MenuView.as_view(), name="menu"),
path(
"preview/<slug:language>/pages-root/",
views.PreviewPageView.as_view(),
name="preview-page-root",
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
views.MenuView.as_view(),
name="menu",
),
path("<slug:language>/menu/<path:path>/", views.MenuView.as_view(), name="menu"),
path(
"preview/<slug:language>/pages-tree/",
views.PreviewPageTreeListView.as_view(),
name="preview-page-tree-list",
),
path(
"preview/<slug:language>/pages-list/",
views.PreviewPageListView.as_view(),
name="preview-page-list",
),
path(
"preview/<slug:language>/pages/<path:path>/",
views.PreviewPageView.as_view(),
name="preview-page",
),
path(
"preview/<slug:language>/placeholders/<int:content_type_id>/<int:object_id>/<str:slot>/",
views.PreviewPlaceholderDetailView.as_view(),
name="preview-placeholder-detail",
"<slug:language>/menu/<path:path>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
views.MenuView.as_view(),
name="menu",
),
]
40 changes: 39 additions & 1 deletion djangocms_rest/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
from django.core.exceptions import FieldError
from django.db.models import QuerySet
from django.http import Http404
from contextlib import contextmanager

from cms.models import Page, PageUrl

Expand Down Expand Up @@ -45,8 +46,45 @@ def get_absolute_frontend_url(request: Request, path: str) -> str:
Returns:
An absolute URL formatted as a string.
"""
if path is None:
return None
protocol = getattr(request, "scheme", "http")
domain = getattr(request, "get_host", lambda: Site.objects.get_current().domain)()
domain = getattr(
request, "get_host", lambda: Site.objects.get_current(request).domain
)()
if not path.startswith("/"):
path = f"/{path}"
return f"{protocol}://{domain}{path}"


@contextmanager
def select_by_api_endpoint(target_class: type, api_endpoint: str):
"""
Context manager that temporarily patches attributes on a class.

Args:
target_class: The class to patch
**patches: Keyword arguments where keys are attribute names and values are the new values

Example:
with patch_class(MyClass, some_method=lambda self: "patched"):
# MyClass.some_method is now patched
instance = MyClass()
assert instance.some_method() == "patched"
# MyClass.some_method is restored to original
"""
# Store original method
original = getattr(target_class, "is_selected")

# Apply the patch
setattr(
target_class,
"is_selected",
lambda self, request: self.api_endpoint == api_endpoint,
)

try:
yield target_class
finally:
# Restore original values
setattr(target_class, "is_selected", original)
Loading