Skip to content
Open
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
11 changes: 2 additions & 9 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 @@ -317,14 +317,7 @@ preview content.
To determine permissions `user_can_view_page()` from djangocms is used, usually editors with
`is_staff` are allowed to view draft content.

| Private Endpoints | Description |
|:-----------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------|
| `/api/preview/{language}/pages-root` | 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. |
| `/api/preview/{language}/placeholders/`<br/>`{content_type_id}/{object_id}/{slot}` | Fetch the latest draft content objects for the given language. |
| |
Just add the `?preview` GET parameter to the above page, page-tree, or page-list endpoints.

### Sample API-Response: api/{en}/pages/{sub}/

Expand Down
56 changes: 55 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 import base


try:
Expand Down Expand Up @@ -42,6 +44,55 @@ 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 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 NavigationNodeMixin:
"""Mixin to add API endpoint and selection logic to NavigationNode."""

def get_api_endpoint(self):
"""Get the API endpoint for the navigation node."""
return self.api_endpoint

def is_selected(self, request):
"""Check if the navigation node is selected."""
return (
self.api_endpoint == request.api_endpoint
if hasattr(request, "api_endpoint")
else super().is_selected(request)
)


class NavigationNodeWithAPI(NavigationNodeMixin, base.NavigationNode):
# NavigationNodeWithAPI must be defined statically at the module level
# to allow it being pickled for cache
pass


def add_api_endpoint(navigation_node: type[base.NavigationNode]):
"""Add an API endpoint to the CMSNavigationNode."""
if not issubclass(navigation_node, NavigationNodeMixin):
navigation_node = NavigationNodeWithAPI
return navigation_node


class RESTToolbarMixin:
"""
Mixin to add REST rendering capabilities to the CMS toolbar.
Expand Down Expand Up @@ -73,3 +124,6 @@ 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

base.NavigationNode = add_api_endpoint(base.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
85 changes: 68 additions & 17 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,81 @@
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(
"preview/<slug:language>/pages-tree/",
views.PreviewPageTreeListView.as_view(),
name="preview-page-tree-list",
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
views.MenuView.as_view(),
name="menu",
),
path(
"preview/<slug:language>/pages-list/",
views.PreviewPageListView.as_view(),
name="preview-page-list",
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
views.MenuView.as_view(),
name="menu",
),
path(
"preview/<slug:language>/pages/<path:path>/",
views.PreviewPageView.as_view(),
name="preview-page",
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"preview/<slug:language>/placeholders/<int:content_type_id>/<int:object_id>/<str:slot>/",
views.PreviewPlaceholderDetailView.as_view(),
name="preview-placeholder-detail",
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/<int:levels>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/<int:levels>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/submenu/",
views.SubMenuView.as_view(),
name="submenu",
),
path(
"<slug:language>/breadcrumbs/<int:start_level>/<path:path>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
),
path(
"<slug:language>/breadcrumbs/<int:start_level>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
),
path(
"<slug:language>/breadcrumbs/<path:path>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
),
path(
"<slug:language>/breadcrumbs/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
),
]
6 changes: 5 additions & 1 deletion djangocms_rest/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,8 +45,12 @@ 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}"
Loading