Skip to content

Commit c141335

Browse files
authored
feat: Add menu endpoints (#49)
* feat: Add serializer for menu nodes * feat: endpoints and noop-views * feat: Working serializer * Fix: request.current_page * perf: Avoid page query for menu-root * feat: Rename endpoints * feat: Replace preview endpoints by "?preview" GET param * Update djangocms_rest/views.py * tests: Add first menu test * Improve tests * fix utils test case * Add menu below id, submenu and breadcrumbs * Update readme * Update tests * fix: No class patching -> better thread-savety * fix: Pickling error * add comment * Update pytest * fix: Remove id, allow `?preview=false`, fix placeholder preview * tests: Add test for preview param * fix: Remove id from menu endpoint * fix: placeholder preview
1 parent 355410b commit c141335

File tree

17 files changed

+508
-113
lines changed

17 files changed

+508
-113
lines changed

README.md

Lines changed: 2 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -304,7 +304,7 @@ disallow/limit public access, or at least implement proper caching.
304304
|:----------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
305305
| `/api/languages/` | Fetch available languages. |
306306
| `/api/plugins/` | Fetch types for all installed plugins. Used for automatic type checks with frontend frameworks. |
307-
| `/api/{language}/pages-root/` | Fetch the root page for a given language. |
307+
| `/api/{language}/pages/` | Fetch the root page for a given language. |
308308
| `/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. |
309309
| `/api/{language}/pages-list/` | Fetch a paginated list. Supports `limit` and `offset` parameters for frontend structure building. |
310310
| `/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. |
@@ -317,14 +317,7 @@ preview content.
317317
To determine permissions `user_can_view_page()` from djangocms is used, usually editors with
318318
`is_staff` are allowed to view draft content.
319319

320-
| Private Endpoints | Description |
321-
|:-----------------------------------------------------------------------------------|:-------------------------------------------------------------------------------------------------------------------|
322-
| `/api/preview/{language}/pages-root` | Fetch the latest draft content for the root page. |
323-
| `/api/preview/{language}/pages-tree` | Fetch the page tree including unpublished pages. |
324-
| `/api/preview/{language}/pages-list` | Fetch a paginated list including unpublished pages. |
325-
| `/api/preview/{language}/pages/{path}` | Fetch the latest draft content from a published or unpublished page, including latest unpublished content objects. |
326-
| `/api/preview/{language}/placeholders/`<br/>`{content_type_id}/{object_id}/{slot}` | Fetch the latest draft content objects for the given language. |
327-
| |
320+
Just add the `?preview` GET parameter to the above page, page-tree, or page-list endpoints.
328321

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

djangocms_rest/cms_config.py

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,8 +4,10 @@
44
from django.urls import NoReverseMatch, reverse
55

66
from cms.app_base import CMSAppConfig
7-
from cms.models import Page
7+
from cms.cms_menus import CMSMenu
8+
from cms.models import Page, PageContent
89
from cms.utils.i18n import force_language, get_current_language
10+
from menus import base
911

1012

1113
try:
@@ -42,6 +44,55 @@ def get_file_api_endpoint(file):
4244
return file.url if file.is_public else None
4345

4446

47+
def patch_get_menu_node_for_page_content(method: callable) -> callable:
48+
def inner(self, page_content: PageContent, *args, **kwargs):
49+
node = method(self, page_content, *args, **kwargs)
50+
node.api_endpoint = get_page_api_endpoint(
51+
page_content.page,
52+
page_content.language,
53+
)
54+
return node
55+
56+
return inner
57+
58+
59+
def patch_page_menu(menu: type[CMSMenu]):
60+
"""Patch the CMSMenu to use the REST API endpoint for pages."""
61+
if hasattr(menu, "get_menu_node_for_page_content"):
62+
menu.get_menu_node_for_page_content = patch_get_menu_node_for_page_content(
63+
menu.get_menu_node_for_page_content
64+
)
65+
66+
67+
class NavigationNodeMixin:
68+
"""Mixin to add API endpoint and selection logic to NavigationNode."""
69+
70+
def get_api_endpoint(self):
71+
"""Get the API endpoint for the navigation node."""
72+
return self.api_endpoint
73+
74+
def is_selected(self, request):
75+
"""Check if the navigation node is selected."""
76+
return (
77+
self.api_endpoint == request.api_endpoint
78+
if hasattr(request, "api_endpoint")
79+
else super().is_selected(request)
80+
)
81+
82+
83+
class NavigationNodeWithAPI(NavigationNodeMixin, base.NavigationNode):
84+
# NavigationNodeWithAPI must be defined statically at the module level
85+
# to allow it being pickled for cache
86+
pass
87+
88+
89+
def add_api_endpoint(navigation_node: type[base.NavigationNode]):
90+
"""Add an API endpoint to the CMSNavigationNode."""
91+
if not issubclass(navigation_node, NavigationNodeMixin):
92+
navigation_node = NavigationNodeWithAPI
93+
return navigation_node
94+
95+
4596
class RESTToolbarMixin:
4697
"""
4798
Mixin to add REST rendering capabilities to the CMS toolbar.
@@ -73,3 +124,6 @@ class RESTCMSConfig(CMSAppConfig):
73124

74125
Page.add_to_class("get_api_endpoint", get_page_api_endpoint)
75126
File.add_to_class("get_api_endpoint", get_file_api_endpoint) if File else None
127+
128+
base.NavigationNode = add_api_endpoint(base.NavigationNode)
129+
patch_page_menu(CMSMenu)
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
from rest_framework import serializers
2+
3+
from menus.base import NavigationNode
4+
5+
from djangocms_rest.utils import get_absolute_frontend_url
6+
7+
8+
class NavigationNodeSerializer(serializers.Serializer):
9+
namespace = serializers.CharField(allow_null=True)
10+
title = serializers.CharField()
11+
url = serializers.URLField(allow_null=True)
12+
api_endpoint = serializers.URLField(allow_null=True)
13+
visible = serializers.BooleanField()
14+
selected = serializers.BooleanField()
15+
attr = serializers.DictField(allow_null=True)
16+
level = serializers.IntegerField(allow_null=True)
17+
children = serializers.SerializerMethodField()
18+
19+
def __init__(self, *args, **kwargs):
20+
super().__init__(*args, **kwargs)
21+
self.request = self.context.get("request")
22+
23+
def get_children(self, obj: NavigationNode) -> list[dict]:
24+
# Assuming obj.children is a list of NavigationNode-like objects
25+
serializer = NavigationNodeSerializer(
26+
obj.children or [], many=True, context=self.context
27+
)
28+
return serializer.data
29+
30+
def to_representation(self, obj: NavigationNode) -> dict:
31+
"""Customize the base representation of the NavigationNode."""
32+
return {
33+
"title": obj.title,
34+
"url": get_absolute_frontend_url(self.request, obj.url),
35+
"api_endpoint": get_absolute_frontend_url(
36+
self.request, getattr(obj, "api_endpoint", None)
37+
),
38+
"visible": obj.visible,
39+
"selected": obj.selected
40+
or obj.attr.get("is_home", False)
41+
and getattr(self.request, "is_home", False),
42+
"attr": obj.attr,
43+
"level": obj.level,
44+
"children": self.get_children(obj),
45+
}
46+
47+
48+
class NavigationNodeListSerializer(serializers.ListSerializer):
49+
child = NavigationNodeSerializer()

djangocms_rest/serializers/pages.py

Lines changed: 7 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -34,13 +34,13 @@ class BasePageSerializer(serializers.Serializer):
3434
changed_date = serializers.DateTimeField()
3535

3636

37-
class PreviewMixin:
38-
"""Mixin to mark content as preview"""
39-
40-
is_preview = True
41-
42-
4337
class BasePageContentMixin:
38+
@property
39+
def is_preview(self):
40+
return "preview" in self.request.GET and self.request.GET.get(
41+
"preview", ""
42+
).lower() not in ("0", "false")
43+
4444
def get_base_representation(self, page_content: PageContent) -> dict:
4545
request = getattr(self, "request", None)
4646
path = page_content.page.get_path(page_content.language)
@@ -150,7 +150,7 @@ def to_representation(self, page_content: PageContent) -> dict:
150150
]
151151
placeholders = [
152152
placeholder
153-
for placeholder in page_content.page.get_placeholders(page_content.language)
153+
for placeholder in page_content.placeholders.all()
154154
if placeholder.slot in declared_slots
155155
]
156156

@@ -173,35 +173,6 @@ def to_representation(self, page_content: PageContent) -> dict:
173173
return data
174174

175175

176-
class PreviewPageContentSerializer(PageContentSerializer, PreviewMixin):
177-
"""Serializer specifically for preview/draft page content"""
178-
179-
placeholders = PlaceholderRelationSerializer(many=True, required=False)
180-
181-
def to_representation(self, page_content: PageContent) -> dict:
182-
# Get placeholders directly from the page_content
183-
# This avoids the extra query to get_declared_placeholders
184-
placeholders = page_content.placeholders.all()
185-
186-
placeholders_data = [
187-
{
188-
"content_type_id": placeholder.content_type_id,
189-
"object_id": placeholder.object_id,
190-
"slot": placeholder.slot,
191-
}
192-
for placeholder in placeholders
193-
]
194-
195-
data = self.get_base_representation(page_content)
196-
data["placeholders"] = PlaceholderRelationSerializer(
197-
placeholders_data,
198-
language=page_content.language,
199-
context={"request": self.request},
200-
many=True,
201-
).data
202-
return data
203-
204-
205176
class PageListSerializer(BasePageSerializer, BasePageContentMixin):
206177
def __init__(self, *args, **kwargs):
207178
super().__init__(*args, **kwargs)

djangocms_rest/urls.py

Lines changed: 68 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@
2121
name="page-list",
2222
),
2323
path(
24-
"<slug:language>/pages-root/",
24+
"<slug:language>/pages/",
2525
views.PageDetailView.as_view(),
2626
name="page-root",
2727
),
@@ -36,30 +36,81 @@
3636
name="placeholder-detail",
3737
),
3838
path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"),
39-
# Preview content endpoints
39+
# Menu endpoints
40+
path("<slug:language>/menu/", views.MenuView.as_view(), name="menu"),
4041
path(
41-
"preview/<slug:language>/pages-root/",
42-
views.PreviewPageView.as_view(),
43-
name="preview-page-root",
42+
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
43+
views.MenuView.as_view(),
44+
name="menu",
4445
),
4546
path(
46-
"preview/<slug:language>/pages-tree/",
47-
views.PreviewPageTreeListView.as_view(),
48-
name="preview-page-tree-list",
47+
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
48+
views.MenuView.as_view(),
49+
name="menu",
4950
),
5051
path(
51-
"preview/<slug:language>/pages-list/",
52-
views.PreviewPageListView.as_view(),
53-
name="preview-page-list",
52+
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
53+
views.MenuView.as_view(),
54+
name="menu",
5455
),
5556
path(
56-
"preview/<slug:language>/pages/<path:path>/",
57-
views.PreviewPageView.as_view(),
58-
name="preview-page",
57+
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/<path:path>/",
58+
views.SubMenuView.as_view(),
59+
name="submenu",
5960
),
6061
path(
61-
"preview/<slug:language>/placeholders/<int:content_type_id>/<int:object_id>/<str:slot>/",
62-
views.PreviewPlaceholderDetailView.as_view(),
63-
name="preview-placeholder-detail",
62+
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/",
63+
views.SubMenuView.as_view(),
64+
name="submenu",
65+
),
66+
path(
67+
"<slug:language>/submenu/<int:levels>/<int:root_level>/<path:path>/",
68+
views.SubMenuView.as_view(),
69+
name="submenu",
70+
),
71+
path(
72+
"<slug:language>/submenu/<int:levels>/<int:root_level>/",
73+
views.SubMenuView.as_view(),
74+
name="submenu",
75+
),
76+
path(
77+
"<slug:language>/submenu/<int:levels>/<path:path>/",
78+
views.SubMenuView.as_view(),
79+
name="submenu",
80+
),
81+
path(
82+
"<slug:language>/submenu/<int:levels>/",
83+
views.SubMenuView.as_view(),
84+
name="submenu",
85+
),
86+
path(
87+
"<slug:language>/submenu/<path:path>/",
88+
views.SubMenuView.as_view(),
89+
name="submenu",
90+
),
91+
path(
92+
"<slug:language>/submenu/",
93+
views.SubMenuView.as_view(),
94+
name="submenu",
95+
),
96+
path(
97+
"<slug:language>/breadcrumbs/<int:start_level>/<path:path>/",
98+
views.BreadcrumbView.as_view(),
99+
name="breadcrumbs",
100+
),
101+
path(
102+
"<slug:language>/breadcrumbs/<int:start_level>/",
103+
views.BreadcrumbView.as_view(),
104+
name="breadcrumbs",
105+
),
106+
path(
107+
"<slug:language>/breadcrumbs/<path:path>/",
108+
views.BreadcrumbView.as_view(),
109+
name="breadcrumbs",
110+
),
111+
path(
112+
"<slug:language>/breadcrumbs/",
113+
views.BreadcrumbView.as_view(),
114+
name="breadcrumbs",
64115
),
65116
]

djangocms_rest/utils.py

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,8 +45,12 @@ def get_absolute_frontend_url(request: Request, path: str) -> str:
4545
Returns:
4646
An absolute URL formatted as a string.
4747
"""
48+
if path is None:
49+
return None
4850
protocol = getattr(request, "scheme", "http")
49-
domain = getattr(request, "get_host", lambda: Site.objects.get_current().domain)()
51+
domain = getattr(
52+
request, "get_host", lambda: Site.objects.get_current(request).domain
53+
)()
5054
if not path.startswith("/"):
5155
path = f"/{path}"
5256
return f"{protocol}://{domain}{path}"

0 commit comments

Comments
 (0)