From db0fd087e71363d60a313aac885b3bd024c49766 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Aug 2025 12:57:58 +0200 Subject: [PATCH 01/22] feat: Add serializer for menu nodes --- djangocms_rest/cms_config.py | 33 ++++++++++++++++++++++++++++- djangocms_rest/serializers/menus.py | 22 +++++++++++++++++++ 2 files changed, 54 insertions(+), 1 deletion(-) create mode 100644 djangocms_rest/serializers/menus.py diff --git a/djangocms_rest/cms_config.py b/djangocms_rest/cms_config.py index bdd3b25..8357220 100644 --- a/djangocms_rest/cms_config.py +++ b/djangocms_rest/cms_config.py @@ -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: @@ -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. @@ -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) diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py new file mode 100644 index 0000000..a3ba1aa --- /dev/null +++ b/djangocms_rest/serializers/menus.py @@ -0,0 +1,22 @@ +from rest_framework import serializers + + +class NavigationNodeSerializer(serializers.Serializer): + id = serializers.IntegerField() + namespace = serializers.CharField(allow_null=True) + title = serializers.CharField() + url = serializers.CharField() + parent_id = serializers.IntegerField(allow_null=True) + visible = serializers.BooleanField() + selected = serializers.BooleanField() + attr = serializers.DictField(allow_null=True) + level = serializers.IntegerField() + + def get_children(self, obj): + # Assuming obj.children is a list of NavigationNode-like objects + serializer = NavigationNodeSerializer(obj.children or [], many=True) + return serializer.data + + +class NavigationNodeListSerializer(serializers.ListSerializer): + child = NavigationNodeSerializer() From 9101c433b0972d113b9b60e592e6eff6d4399920 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Aug 2025 13:10:27 +0200 Subject: [PATCH 02/22] feat: endpoints and noop-views --- djangocms_rest/urls.py | 8 ++++++++ djangocms_rest/views.py | 37 +++++++++++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index cba7bdc..727ffcf 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -36,6 +36,14 @@ name="placeholder-detail", ), path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"), + # Menu endpoints + path("/menu-root/", views.MenuView.as_view(), name="menu"), + path("/menu//", views.MenuView.as_view(), name="menu"), + path( + "/menu//////", + views.MenuView.as_view(), + name="menu", + ), # Preview content endpoints path( "preview//pages-root/", diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 3763307..0a834f4 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -1,3 +1,4 @@ +from typing import Any from django.contrib.sites.shortcuts import get_current_site from django.utils.functional import lazy @@ -13,6 +14,7 @@ from djangocms_rest.permissions import CanViewPage, IsAllowedPublicLanguage from djangocms_rest.serializers.languages import LanguageSerializer +from djangocms_rest.serializers.menus import NavigationNodeSerializer from djangocms_rest.serializers.pages import ( PageContentSerializer, PageListSerializer, @@ -241,6 +243,41 @@ def get(self, request: Request) -> Response: return Response(definitions) +class MenuView(BaseAPIView): + permission_classes = [IsAllowedPublicLanguage] + serializer_class = NavigationNodeSerializer + + def get( + self, + request: Request, + language: str, + path: str = "/", # for menu-root endpoint + from_level: int = 0, # Defaults from django CMS' menus app + to_level: int = 100, + extra_inactive: int = 0, + extra_active: int = 1000, + ) -> Response: + """Get the menu structure for a specific language and path.""" + menu = self.get_menu_structure( + language, path, from_level, to_level, extra_inactive, extra_active + ) + serializer = self.serializer_class(menu, many=True) + return Response(serializer.data) + + def get_menu_structure( + self, + language: str, + path: str, + from_level: int, + to_level: int, + extra_inactive: int, + extra_active: int, + ) -> list[dict[str, Any]]: + """Get the menu structure for a specific language and path.""" + # Implement the logic to retrieve the menu structure + return [] + + class PreviewPageView(PageDetailView): content_getter = "get_admin_content" serializer_class = PreviewPageContentSerializer From e2e47074c80d617feeab5ad2083bbb2df06a53f2 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Aug 2025 19:29:49 +0200 Subject: [PATCH 03/22] feat: Working serializer --- djangocms_rest/serializers/menus.py | 33 +++++++++++++++--- djangocms_rest/urls.py | 5 +++ djangocms_rest/utils.py | 36 +++++++++++++++++++- djangocms_rest/views.py | 53 ++++++++++++++++++++++++++--- 4 files changed, 117 insertions(+), 10 deletions(-) diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py index a3ba1aa..2da7dcf 100644 --- a/djangocms_rest/serializers/menus.py +++ b/djangocms_rest/serializers/menus.py @@ -1,22 +1,47 @@ 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.CharField() - parent_id = serializers.IntegerField(allow_null=True) + 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() + children = serializers.SerializerMethodField() - def get_children(self, obj): + 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) + 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, + "attr": obj.attr, + "level": obj.level, + "children": self.get_children(obj), + } + class NavigationNodeListSerializer(serializers.ListSerializer): child = NavigationNodeSerializer() diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index 727ffcf..a353072 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -38,6 +38,11 @@ path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"), # Menu endpoints path("/menu-root/", views.MenuView.as_view(), name="menu"), + path( + "/menu-root/////", + views.MenuView.as_view(), + name="menu", + ), path("/menu//", views.MenuView.as_view(), name="menu"), path( "/menu//////", diff --git a/djangocms_rest/utils.py b/djangocms_rest/utils.py index f2d6aef..842316a 100644 --- a/djangocms_rest/utils.py +++ b/djangocms_rest/utils.py @@ -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 @@ -45,8 +46,41 @@ 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: self.api_endpoint == api_endpoint) + + try: + yield target_class + finally: + # Restore original values + setattr(target_class, "is_selected", original) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 0a834f4..5a9fb46 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -1,10 +1,12 @@ from typing import Any from django.contrib.sites.shortcuts import get_current_site +from django.urls import reverse from django.utils.functional import lazy from cms.models import Page, PageContent, Placeholder from cms.utils.conf import get_languages from cms.utils.page_permissions import user_can_view_page +from menus.base import NavigationNode from rest_framework.exceptions import NotFound from rest_framework.pagination import LimitOffsetPagination @@ -23,7 +25,11 @@ ) from djangocms_rest.serializers.placeholders import PlaceholderSerializer from djangocms_rest.serializers.plugins import PluginDefinitionSerializer -from djangocms_rest.utils import get_object, get_site_filtered_queryset +from djangocms_rest.utils import ( + get_object, + get_site_filtered_queryset, + select_by_api_endpoint, +) from djangocms_rest.views_base import BaseAPIView, BaseListAPIView @@ -145,6 +151,7 @@ def get(self, request: Request, language: str, path: str = "") -> Response: site = self.site page = get_object(site, path) self.check_object_permissions(request, page) + request.current_page = page try: page_content = getattr(page, self.content_getter)(language, fallback=True) @@ -251,7 +258,7 @@ def get( self, request: Request, language: str, - path: str = "/", # for menu-root endpoint + path: str = "", # for menu-root endpoint from_level: int = 0, # Defaults from django CMS' menus app to_level: int = 100, extra_inactive: int = 0, @@ -259,13 +266,16 @@ def get( ) -> Response: """Get the menu structure for a specific language and path.""" menu = self.get_menu_structure( - language, path, from_level, to_level, extra_inactive, extra_active + request, language, path, from_level, to_level, extra_inactive, extra_active + ) + serializer = self.serializer_class( + menu, many=True, context={"request": request} ) - serializer = self.serializer_class(menu, many=True) return Response(serializer.data) def get_menu_structure( self, + request: Request, language: str, path: str, from_level: int, @@ -275,7 +285,40 @@ def get_menu_structure( ) -> list[dict[str, Any]]: """Get the menu structure for a specific language and path.""" # Implement the logic to retrieve the menu structure - return [] + from menus.templatetags.menu_tags import ShowMenu + + # Create tag instance without calling __init__ + tag_instance = ShowMenu.__new__(ShowMenu) + + # Initialize minimal necessary attributes + tag_instance.kwargs = {} + tag_instance.blocks = {} + + request.LANGUAGE_CODE = language + context = {"request": request} + + request.current_page = get_object(self.site, path) + self.check_object_permissions(request, request.current_page) + + if path == "": + api_endpoint = reverse("page-root", kwargs={"language": language}) + else: + api_endpoint = reverse( + "page-detail", kwargs={"language": language, "path": path} + ) + with select_by_api_endpoint(NavigationNode, api_endpoint): + context = tag_instance.get_context( + context, + from_level, + to_level, + extra_inactive, + extra_active, + template=None, + namespace=None, + root_id=None, + next_page=None, + ) + return context.get("children", []) class PreviewPageView(PageDetailView): From c5956d07f9a3fd711deaa3bbffb7332fc6bfa0c1 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Aug 2025 19:31:25 +0200 Subject: [PATCH 04/22] Fix: request.current_page --- djangocms_rest/views.py | 1 - 1 file changed, 1 deletion(-) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 5a9fb46..3124ab4 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -151,7 +151,6 @@ def get(self, request: Request, language: str, path: str = "") -> Response: site = self.site page = get_object(site, path) self.check_object_permissions(request, page) - request.current_page = page try: page_content = getattr(page, self.content_getter)(language, fallback=True) From 352b2a401898f3460db0e2eef09d355fbec22c3e Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 29 Aug 2025 20:23:18 +0200 Subject: [PATCH 05/22] perf: Avoid page query for menu-root --- djangocms_rest/serializers/menus.py | 4 +++- djangocms_rest/utils.py | 6 +++++- djangocms_rest/views.py | 6 ++++-- 3 files changed, 12 insertions(+), 4 deletions(-) diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py index 2da7dcf..d19612e 100644 --- a/djangocms_rest/serializers/menus.py +++ b/djangocms_rest/serializers/menus.py @@ -36,7 +36,9 @@ def to_representation(self, obj: NavigationNode) -> dict: "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, + "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), diff --git a/djangocms_rest/utils.py b/djangocms_rest/utils.py index 842316a..7c9798b 100644 --- a/djangocms_rest/utils.py +++ b/djangocms_rest/utils.py @@ -77,7 +77,11 @@ def select_by_api_endpoint(target_class: type, api_endpoint: str): original = getattr(target_class, "is_selected") # Apply the patch - setattr(target_class, "is_selected", lambda self: self.api_endpoint == api_endpoint) + setattr( + target_class, + "is_selected", + lambda self, request: self.api_endpoint == api_endpoint, + ) try: yield target_class diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 3124ab4..96d77e8 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -296,15 +296,17 @@ def get_menu_structure( request.LANGUAGE_CODE = language context = {"request": request} - request.current_page = get_object(self.site, path) self.check_object_permissions(request, request.current_page) if path == "": api_endpoint = reverse("page-root", kwargs={"language": language}) - else: + request.is_home = True # Let serializer select the home page + if path: api_endpoint = reverse( "page-detail", kwargs={"language": language, "path": path} ) + request.current_page = get_object(self.site, path) + with select_by_api_endpoint(NavigationNode, api_endpoint): context = tag_instance.get_context( context, From 5a2c83f051fd3c502bb7ca508549d831169d5f8a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 1 Sep 2025 08:30:17 +0200 Subject: [PATCH 06/22] feat: Rename endpoints --- README.md | 4 ++-- djangocms_rest/serializers/menus.py | 2 +- djangocms_rest/urls.py | 16 ++++++++-------- tests/endpoints/test_page_root.py | 2 +- tests/test_plugin_renderer.py | 2 +- 5 files changed, 13 insertions(+), 13 deletions(-) diff --git a/README.md b/README.md index acb47f3..250b4d8 100644 --- a/README.md +++ b/README.md @@ -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. | @@ -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. | diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py index d19612e..7a5313b 100644 --- a/djangocms_rest/serializers/menus.py +++ b/djangocms_rest/serializers/menus.py @@ -14,7 +14,7 @@ class NavigationNodeSerializer(serializers.Serializer): visible = serializers.BooleanField() selected = serializers.BooleanField() attr = serializers.DictField(allow_null=True) - level = serializers.IntegerField() + level = serializers.IntegerField(allow_null=True) children = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index a353072..7029df4 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -21,7 +21,7 @@ name="page-list", ), path( - "/pages-root/", + "/pages/", views.PageDetailView.as_view(), name="page-root", ), @@ -37,9 +37,9 @@ ), path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"), # Menu endpoints - path("/menu-root/", views.MenuView.as_view(), name="menu"), + path("/menu/", views.MenuView.as_view(), name="menu"), path( - "/menu-root/////", + "/menu/////", views.MenuView.as_view(), name="menu", ), @@ -50,11 +50,6 @@ name="menu", ), # Preview content endpoints - path( - "preview//pages-root/", - views.PreviewPageView.as_view(), - name="preview-page-root", - ), path( "preview//pages-tree/", views.PreviewPageTreeListView.as_view(), @@ -65,6 +60,11 @@ views.PreviewPageListView.as_view(), name="preview-page-list", ), + path( + "preview//pages/", + views.PreviewPageView.as_view(), + name="preview-page-root", + ), path( "preview//pages//", views.PreviewPageView.as_view(), diff --git a/tests/endpoints/test_page_root.py b/tests/endpoints/test_page_root.py index 5f92fb0..ba002a4 100644 --- a/tests/endpoints/test_page_root.py +++ b/tests/endpoints/test_page_root.py @@ -8,7 +8,7 @@ class PageRootAPITestCase(BaseCMSRestTestCase): def test_get(self): """ - Test the page root endpoint ('/api/{language}/pages-root/'). + Test the page root endpoint ('/api/{language}/pages/'). Verifies: - Endpoint returns correct HTTP status code diff --git a/tests/test_plugin_renderer.py b/tests/test_plugin_renderer.py index fa60999..0a17cd2 100644 --- a/tests/test_plugin_renderer.py +++ b/tests/test_plugin_renderer.py @@ -178,7 +178,7 @@ def test_edit_endpoint(self): # Test link plugin resolves link to page API endpoint self.assertContains( response, - '"page": "http://testserver/api/en/pages-root/"', + '"page": "http://testserver/api/en/pages/"', ) # Test image plugin resolves image URL From c705a369bcbc01daf7d25f6d0391213f46d9c011 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 07:56:41 +0200 Subject: [PATCH 07/22] feat: Replace preview endpoints by "?preview" GET param --- djangocms_rest/serializers/pages.py | 41 ++++---------------------- djangocms_rest/urls.py | 26 ---------------- djangocms_rest/views.py | 22 -------------- djangocms_rest/views_base.py | 35 ++++++++++++++++------ tests/endpoints/test_page_list.py | 6 ++-- tests/endpoints/test_page_root.py | 8 +++-- tests/endpoints/test_page_tree_list.py | 6 ++-- tests/endpoints/test_placeholders.py | 6 ++-- 8 files changed, 46 insertions(+), 104 deletions(-) diff --git a/djangocms_rest/serializers/pages.py b/djangocms_rest/serializers/pages.py index 19d405c..6427e3b 100644 --- a/djangocms_rest/serializers/pages.py +++ b/djangocms_rest/serializers/pages.py @@ -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) @@ -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 ] @@ -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) diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index 7029df4..149bd66 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -49,30 +49,4 @@ views.MenuView.as_view(), name="menu", ), - # Preview content endpoints - path( - "preview//pages-tree/", - views.PreviewPageTreeListView.as_view(), - name="preview-page-tree-list", - ), - path( - "preview//pages-list/", - views.PreviewPageListView.as_view(), - name="preview-page-list", - ), - path( - "preview//pages/", - views.PreviewPageView.as_view(), - name="preview-page-root", - ), - path( - "preview//pages//", - views.PreviewPageView.as_view(), - name="preview-page", - ), - path( - "preview//placeholders////", - views.PreviewPlaceholderDetailView.as_view(), - name="preview-placeholder-detail", - ), ] diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 96d77e8..1505a6c 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -21,7 +21,6 @@ PageContentSerializer, PageListSerializer, PageMetaSerializer, - PreviewPageContentSerializer, ) from djangocms_rest.serializers.placeholders import PlaceholderSerializer from djangocms_rest.serializers.plugins import PluginDefinitionSerializer @@ -84,7 +83,6 @@ class PageListView(BaseListAPIView): permission_classes = [IsAllowedPublicLanguage] serializer_class = PageListSerializer pagination_class = LimitOffsetPagination - content_getter = "get_content_obj" def get_queryset(self): """Get queryset of pages for the given language.""" @@ -111,7 +109,6 @@ def get_queryset(self): class PageTreeListView(BaseAPIView): permission_classes = [IsAllowedPublicLanguage] serializer_class = PageMetaSerializer - content_getter = "get_content_obj" def get(self, request, language): """List of all pages on this site for a given language.""" @@ -143,7 +140,6 @@ def get(self, request, language): class PageDetailView(BaseAPIView): permission_classes = [IsAllowedPublicLanguage, CanViewPage] serializer_class = PageContentSerializer - content_getter = "get_content_obj" def get(self, request: Request, language: str, path: str = "") -> Response: """Retrieve a page instance. The page instance includes the placeholders and @@ -320,21 +316,3 @@ def get_menu_structure( next_page=None, ) return context.get("children", []) - - -class PreviewPageView(PageDetailView): - content_getter = "get_admin_content" - serializer_class = PreviewPageContentSerializer - permission_classes = [IsAdminUser, CanViewPage] - - -class PreviewPageTreeListView(PageTreeListView): - content_getter = "get_admin_content" - serializer_class = PageMetaSerializer - permission_classes = [IsAdminUser, CanViewPage] - - -class PreviewPageListView(PageListView): - content_getter = "get_admin_content" - serializer_class = PageMetaSerializer - permission_classes = [IsAdminUser, CanViewPage] diff --git a/djangocms_rest/views_base.py b/djangocms_rest/views_base.py index 9f5faef..265596b 100644 --- a/djangocms_rest/views_base.py +++ b/djangocms_rest/views_base.py @@ -2,12 +2,13 @@ from django.utils.functional import cached_property from rest_framework.generics import ListAPIView +from rest_framework.permissions import IsAdminUser from rest_framework.views import APIView -class BaseAPIView(APIView): +class BaseAPIMixin: """ - This is a base class for all API views. It sets the allowed methods to GET and OPTIONS. + This mixin provides common functionality for all API views. """ http_method_names = ("get", "options") @@ -19,15 +20,31 @@ def site(self): """ return get_current_site(self.request) + @property + def content_getter(self): + if "preview" in self.request.GET: + return "get_admin_content" + return "get_content_obj" + + def get_permissions(self): + permissions = super().get_permissions() + if "preview" in self.request.GET: + # Require admin access for preview as first check + permissions.insert(0, IsAdminUser()) + return permissions -class BaseListAPIView(ListAPIView): + +class BaseAPIView(BaseAPIMixin, APIView): + """ + This is a base class for all API views. It sets the allowed methods to GET and OPTIONS. + """ + + pass + + +class BaseListAPIView(BaseAPIMixin, ListAPIView): """ This is a base class for all list API views. It supports default pagination and sets the allowed methods to GET and OPTIONS. """ - @cached_property - def site(self): - """ - Fetch and cache the current site and make it available to all views. - """ - return get_current_site(self.request) + pass diff --git a/tests/endpoints/test_page_list.py b/tests/endpoints/test_page_list.py index 54a9d18..5719b95 100644 --- a/tests/endpoints/test_page_list.py +++ b/tests/endpoints/test_page_list.py @@ -61,12 +61,12 @@ def test_get_paginated_list(self): # GET PREVIEW response = self.client.get( - reverse("preview-page-list", kwargs={"language": "en"}) + reverse("page-list", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 403) response = self.client.get( - reverse("preview-page-list", kwargs={"language": "xx"}) + reverse("page-list", kwargs={"language": "xx"}) + "?preview" ) self.assertEqual(response.status_code, 403) @@ -74,6 +74,6 @@ def test_get_paginated_list(self): def test_get_protected(self): self.client.force_login(self.user) response = self.client.get( - reverse("preview-page-list", kwargs={"language": "en"}) + reverse("page-list", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 200) diff --git a/tests/endpoints/test_page_root.py b/tests/endpoints/test_page_root.py index ba002a4..3c6beaa 100644 --- a/tests/endpoints/test_page_root.py +++ b/tests/endpoints/test_page_root.py @@ -26,6 +26,7 @@ def test_get(self): response = self.client.get(reverse("page-root", kwargs={"language": "en"})) self.assertEqual(response.status_code, 200) page = response.json() + self.assertFalse(response.json().get("is_preview")) # Data & Type Validation for field, expected_type in type_checks.items(): @@ -45,12 +46,12 @@ def test_get(self): # GET PREVIEW response = self.client.get( - reverse("preview-page-root", kwargs={"language": "en"}) + reverse("page-root", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 403) response = self.client.get( - reverse("preview-page-root", kwargs={"language": "xx"}) + reverse("page-root", kwargs={"language": "xx"}) + "?preview" ) self.assertEqual(response.status_code, 403) @@ -58,6 +59,7 @@ def test_get(self): def test_get_protected(self): self.client.force_login(self.user) response = self.client.get( - reverse("preview-page-root", kwargs={"language": "en"}) + reverse("page-root", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 200) + self.assertTrue(response.json().get("is_preview")) diff --git a/tests/endpoints/test_page_tree_list.py b/tests/endpoints/test_page_tree_list.py index 7abb716..903453d 100644 --- a/tests/endpoints/test_page_tree_list.py +++ b/tests/endpoints/test_page_tree_list.py @@ -53,12 +53,12 @@ def test_get(self): # GET PREVIEW response = self.client.get( - reverse("preview-page-tree-list", kwargs={"language": "en"}) + reverse("page-tree-list", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 403) response = self.client.get( - reverse("preview-page-tree-list", kwargs={"language": "xx"}) + reverse("page-tree-list", kwargs={"language": "xx"}) + "?preview" ) self.assertEqual(response.status_code, 403) @@ -66,7 +66,7 @@ def test_get(self): def test_get_protected(self): self.client.force_login(self.user) response = self.client.get( - reverse("preview-page-tree-list", kwargs={"language": "en"}) + reverse("page-tree-list", kwargs={"language": "en"}) + "?preview" ) self.assertEqual(response.status_code, 200) diff --git a/tests/endpoints/test_placeholders.py b/tests/endpoints/test_placeholders.py index a1c6197..0666855 100644 --- a/tests/endpoints/test_placeholders.py +++ b/tests/endpoints/test_placeholders.py @@ -171,7 +171,7 @@ def test_get(self): # GET PREVIEW response = self.client.get( reverse( - "preview-placeholder-detail", + "placeholder-detail", kwargs={ "language": "en", "content_type_id": self.page_content_type.id, @@ -179,6 +179,7 @@ def test_get(self): "slot": "content", }, ) + + "?preview" ) self.assertEqual(response.status_code, 403) @@ -187,7 +188,7 @@ def test_get_protected(self): self.client.force_login(self.user) response = self.client.get( reverse( - "preview-placeholder-detail", + "placeholder-detail", kwargs={ "language": "en", "content_type_id": self.page_content_type.id, @@ -195,6 +196,7 @@ def test_get_protected(self): "slot": "content", }, ) + + "?preview" ) self.assertEqual(response.status_code, 200) From 8675f4d1571fb35574e0bd46eb8311ecf0b781a3 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 14:24:12 +0200 Subject: [PATCH 08/22] Update djangocms_rest/views.py --- djangocms_rest/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 1505a6c..d7e4c93 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -297,7 +297,7 @@ def get_menu_structure( if path == "": api_endpoint = reverse("page-root", kwargs={"language": language}) request.is_home = True # Let serializer select the home page - if path: + else: api_endpoint = reverse( "page-detail", kwargs={"language": language, "path": path} ) From 37fe8bb8693c39ec53a41cc1aa81a53e032032cb Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 14:45:42 +0200 Subject: [PATCH 09/22] tests: Add first menu test --- djangocms_rest/views.py | 7 ++-- tests/base.py | 8 ++++- tests/endpoints/test_menu.py | 70 ++++++++++++++++++++++++++++++++++++ tests/text_utils.py | 13 +++++++ 4 files changed, 95 insertions(+), 3 deletions(-) create mode 100644 tests/endpoints/test_menu.py create mode 100644 tests/text_utils.py diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index d7e4c93..e4c9bab 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -7,6 +7,8 @@ from cms.utils.conf import get_languages from cms.utils.page_permissions import user_can_view_page from menus.base import NavigationNode +from menus.templatetags.menu_tags import ShowMenu + from rest_framework.exceptions import NotFound from rest_framework.pagination import LimitOffsetPagination @@ -249,6 +251,8 @@ class MenuView(BaseAPIView): permission_classes = [IsAllowedPublicLanguage] serializer_class = NavigationNodeSerializer + tag = ShowMenu + def get( self, request: Request, @@ -280,10 +284,9 @@ def get_menu_structure( ) -> list[dict[str, Any]]: """Get the menu structure for a specific language and path.""" # Implement the logic to retrieve the menu structure - from menus.templatetags.menu_tags import ShowMenu # Create tag instance without calling __init__ - tag_instance = ShowMenu.__new__(ShowMenu) + tag_instance = self.tag.__new__(self.tag) # Initialize minimal necessary attributes tag_instance.kwargs = {} diff --git a/tests/base.py b/tests/base.py index f20f87d..cc718aa 100644 --- a/tests/base.py +++ b/tests/base.py @@ -21,7 +21,13 @@ def _create_pages( is_first: bool = True, ): new_pages = [ - create_page(f"page {i}", language="en", template="INHERIT", parent=parent) + create_page( + f"page {i}", + language="en", + template="INHERIT", + parent=parent, + in_navigation=True, + ) for i in range(page_list if isinstance(page_list, int) else len(page_list)) ] diff --git a/tests/endpoints/test_menu.py b/tests/endpoints/test_menu.py new file mode 100644 index 0000000..2b62576 --- /dev/null +++ b/tests/endpoints/test_menu.py @@ -0,0 +1,70 @@ +from django.contrib.sites.models import Site +from rest_framework.reverse import reverse + +from tests.base import BaseCMSRestTestCase + +from cms.models import Page + + +class PageListAPITestCase(BaseCMSRestTestCase): + def test_get_menu_default(self): + """ + Test the menu endpoint (/api/{language}/menu/). + + Verifies: + - Endpoint returns correct HTTP status code + - Response contains paginated structure + - All pages contain required fields + - All fields have correct data types + - Pagination metadata is present + - Invalid language code returns 404 + """ + + # Get current site + site = Site.objects.get_current() + expected_length = Page.objects.filter(site=site, parent=None).count() + + # GET + url = reverse("menu", kwargs={"language": "en"}) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + results = response.json() + + # Number of results: + self.assertEqual(len(results), expected_length) + + # Page titles: + self.assertEqual(results[0]["title"], "page 0") + self.assertEqual(results[1]["title"], "page 1") + self.assertEqual(results[2]["title"], "page 2") + + # No children: + self.assertEqual(results[0]["children"], []) + self.assertEqual(results[1]["children"], []) + self.assertEqual(results[2]["children"], []) + + def x_test_get_menu_with_children(self): + """ + Test the menu endpoint (/api/{language}/menu/) with child pages. + + Verifies: + - Child pages are included in the response + - Child pages have correct titles and structure + """ + + # GET + url = reverse( + "menu", + kwargs={ + "language": "en", + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + results = response.json() + + # Check if the child page is included + self.assertIn("children", results[0]) + self.assertEqual(len(results[0]["children"]), 1) + self.assertEqual(results[0]["children"][0]["title"], "Child Page") diff --git a/tests/text_utils.py b/tests/text_utils.py new file mode 100644 index 0000000..adbee13 --- /dev/null +++ b/tests/text_utils.py @@ -0,0 +1,13 @@ +from django.test import TestCase + +from djangocms_rest.utils import get_absolute_frontend_url + + +class UtilityTestCase(TestCase): + def test_get_absolute_frontend_url_adds_site(self): + url = get_absolute_frontend_url("/some/path/") + self.assertEqual(url, "http://testserver/some/path/") + + def test_get_absolute_frontend_url_keeps_none(self): + url = get_absolute_frontend_url(None) + self.asserIsNone(url) From caf21ff6686654e8b31b49dbe3a062a58302bef5 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 16:57:23 +0200 Subject: [PATCH 10/22] Improve tests --- djangocms_rest/urls.py | 3 +-- djangocms_rest/views.py | 10 ++++---- tests/endpoints/test_menu.py | 50 ++++++++++++++++++++++++++++++++---- 3 files changed, 51 insertions(+), 12 deletions(-) diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index 149bd66..1feb44b 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -43,9 +43,8 @@ views.MenuView.as_view(), name="menu", ), - path("/menu//", views.MenuView.as_view(), name="menu"), path( - "/menu//////", + "/menu//////", views.MenuView.as_view(), name="menu", ), diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index e4c9bab..1fb2483 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -308,11 +308,11 @@ def get_menu_structure( with select_by_api_endpoint(NavigationNode, api_endpoint): context = tag_instance.get_context( - context, - from_level, - to_level, - extra_inactive, - extra_active, + context=context, + from_level=from_level, + to_level=to_level, + extra_inactive=extra_inactive, + extra_active=extra_active, template=None, namespace=None, root_id=None, diff --git a/tests/endpoints/test_menu.py b/tests/endpoints/test_menu.py index 2b62576..a4b49f9 100644 --- a/tests/endpoints/test_menu.py +++ b/tests/endpoints/test_menu.py @@ -25,7 +25,16 @@ def test_get_menu_default(self): expected_length = Page.objects.filter(site=site, parent=None).count() # GET - url = reverse("menu", kwargs={"language": "en"}) + url = reverse( + "menu", + kwargs={ + "language": "en", + "from_level": 0, + "to_level": 100, + "extra_inactive": 0, + "extra_active": 100, + }, + ) response = self.client.get(url) self.assertEqual(response.status_code, 200) results = response.json() @@ -43,9 +52,14 @@ def test_get_menu_default(self): self.assertEqual(results[1]["children"], []) self.assertEqual(results[2]["children"], []) - def x_test_get_menu_with_children(self): + # Selected: Root page + self.assertTrue(results[0]["selected"]) + self.assertFalse(results[1]["selected"]) + self.assertFalse(results[2]["selected"]) + + def test_get_menu_with_children(self): """ - Test the menu endpoint (/api/{language}/menu/) with child pages. + Test the menu endpoint (/api/{language}/menu/{path}/) with child pages. Verifies: - Child pages are included in the response @@ -57,6 +71,11 @@ def x_test_get_menu_with_children(self): "menu", kwargs={ "language": "en", + "path": "page-2", + "from_level": 0, + "to_level": 100, + "extra_inactive": 0, + "extra_active": 100, }, ) response = self.client.get(url) @@ -66,5 +85,26 @@ def x_test_get_menu_with_children(self): # Check if the child page is included self.assertIn("children", results[0]) - self.assertEqual(len(results[0]["children"]), 1) - self.assertEqual(results[0]["children"][0]["title"], "Child Page") + self.assertEqual(len(results[2]["children"]), 2) + self.assertEqual(results[2]["children"][0]["title"], "page 0") + + def test_default_levels(self): + url1 = reverse( + "menu", + kwargs={"language": "en"}, + ) + url2 = reverse( + "menu", + kwargs={ + "language": "en", + "from_level": 0, + "to_level": 100, + "extra_inactive": 0, + "extra_active": 1000, + }, + ) + results1 = self.client.get(url1).json() + results2 = self.client.get(url2).json() + + self.assertNotEqual(url1, url2) + self.assertEqual(results1, results2) From e1b2358a7c41dd5ff0f5733181f8dece6e390092 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 17:02:59 +0200 Subject: [PATCH 11/22] fix utils test case --- tests/test_utils.py | 16 ++++++++++++++++ tests/text_utils.py | 13 ------------- 2 files changed, 16 insertions(+), 13 deletions(-) create mode 100644 tests/test_utils.py delete mode 100644 tests/text_utils.py diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..00167af --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,16 @@ +from django.test import TestCase +from django.test import RequestFactory + +from djangocms_rest.utils import get_absolute_frontend_url + + +class UtilityTestCase(TestCase): + def test_get_absolute_frontend_url_adds_site(self): + request = RequestFactory().get("http://testserver/") + url = get_absolute_frontend_url(request, "/some/path/") + self.assertEqual(url, "http://testserver/some/path/") + + def test_get_absolute_frontend_url_keeps_none(self): + request = RequestFactory().get("http://testserver/") + url = get_absolute_frontend_url(request, None) + self.assertIsNone(url) diff --git a/tests/text_utils.py b/tests/text_utils.py deleted file mode 100644 index adbee13..0000000 --- a/tests/text_utils.py +++ /dev/null @@ -1,13 +0,0 @@ -from django.test import TestCase - -from djangocms_rest.utils import get_absolute_frontend_url - - -class UtilityTestCase(TestCase): - def test_get_absolute_frontend_url_adds_site(self): - url = get_absolute_frontend_url("/some/path/") - self.assertEqual(url, "http://testserver/some/path/") - - def test_get_absolute_frontend_url_keeps_none(self): - url = get_absolute_frontend_url(None) - self.asserIsNone(url) From 4e3b4810ee505d1ec0eb15284f2f4daefc27cc6a Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 21:32:41 +0200 Subject: [PATCH 12/22] Add menu below id, submenu and breadcrumbs --- djangocms_rest/urls.py | 65 ++++++++++++++++++++++++++++++++++++ djangocms_rest/views.py | 64 ++++++++++++++++++++++------------- tests/endpoints/test_menu.py | 4 +-- 3 files changed, 107 insertions(+), 26 deletions(-) diff --git a/djangocms_rest/urls.py b/djangocms_rest/urls.py index 1feb44b..c0bf82e 100644 --- a/djangocms_rest/urls.py +++ b/djangocms_rest/urls.py @@ -48,4 +48,69 @@ views.MenuView.as_view(), name="menu", ), + path( + "/menu///////", + views.MenuView.as_view(), + name="menu", + ), + path( + "/submenu/////", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu////", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu////", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu///", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu///", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu//", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu//", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/submenu/", + views.SubMenuView.as_view(), + name="submenu", + ), + path( + "/breadcrumbs///", + views.BreadcrumbView.as_view(), + name="breadcrumbs", + ), + path( + "/breadcrumbs//", + views.BreadcrumbView.as_view(), + name="breadcrumbs", + ), + path( + "/breadcrumbs//", + views.BreadcrumbView.as_view(), + name="breadcrumbs", + ), + path( + "/breadcrumbs/", + views.BreadcrumbView.as_view(), + name="breadcrumbs", + ), ] diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 1fb2483..e2b6a68 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from typing import Any from django.contrib.sites.shortcuts import get_current_site from django.urls import reverse @@ -7,7 +9,7 @@ from cms.utils.conf import get_languages from cms.utils.page_permissions import user_can_view_page from menus.base import NavigationNode -from menus.templatetags.menu_tags import ShowMenu +from menus.templatetags.menu_tags import ShowMenu, ShowSubMenu, ShowBreadcrumb from rest_framework.exceptions import NotFound @@ -252,35 +254,39 @@ class MenuView(BaseAPIView): serializer_class = NavigationNodeSerializer tag = ShowMenu + return_key = "children" def get( self, request: Request, language: str, path: str = "", # for menu-root endpoint - from_level: int = 0, # Defaults from django CMS' menus app - to_level: int = 100, - extra_inactive: int = 0, - extra_active: int = 1000, + **kwargs: dict[str, Any], ) -> Response: """Get the menu structure for a specific language and path.""" - menu = self.get_menu_structure( - request, language, path, from_level, to_level, extra_inactive, extra_active - ) + self.populate_defaults(kwargs) + menu = self.get_menu_structure(request, language, path, **kwargs) serializer = self.serializer_class( menu, many=True, context={"request": request} ) return Response(serializer.data) + def populate_defaults(self, kwargs: dict[str, Any]) -> None: + """Set default values for menu view parameters.""" + kwargs.setdefault("from_level", 0) + kwargs.setdefault("to_level", 100) + kwargs.setdefault("extra_inactive", 0) + kwargs.setdefault("extra_active", 1000) + kwargs.setdefault("root_id", None) + kwargs.setdefault("namespace", None) + kwargs.setdefault("next_page", None) + def get_menu_structure( self, request: Request, language: str, path: str, - from_level: int, - to_level: int, - extra_inactive: int, - extra_active: int, + **kwargs: dict[str, Any], ) -> list[dict[str, Any]]: """Get the menu structure for a specific language and path.""" # Implement the logic to retrieve the menu structure @@ -293,29 +299,39 @@ def get_menu_structure( tag_instance.blocks = {} request.LANGUAGE_CODE = language - context = {"request": request} - + request.current_page = get_object(self.site, path) self.check_object_permissions(request, request.current_page) + context = {"request": request} if path == "": api_endpoint = reverse("page-root", kwargs={"language": language}) - request.is_home = True # Let serializer select the home page else: api_endpoint = reverse( "page-detail", kwargs={"language": language, "path": path} ) - request.current_page = get_object(self.site, path) with select_by_api_endpoint(NavigationNode, api_endpoint): context = tag_instance.get_context( context=context, - from_level=from_level, - to_level=to_level, - extra_inactive=extra_inactive, - extra_active=extra_active, + **kwargs, template=None, - namespace=None, - root_id=None, - next_page=None, ) - return context.get("children", []) + return context.get(self.return_key, []) + + +class SubMenuView(MenuView): + tag = ShowSubMenu + + def populate_defaults(self, kwargs: dict[str, Any]) -> None: + kwargs.setdefault("levels", 100) + kwargs.setdefault("root_level", None) + kwargs.setdefault("nephews", 100) + + +class BreadcrumbView(MenuView): + tag = ShowBreadcrumb + return_key = "ancestors" + + def populate_defaults(self, kwargs: dict[str, Any]) -> None: + kwargs.setdefault("start_level", 0) + kwargs.setdefault("only_visible", True) diff --git a/tests/endpoints/test_menu.py b/tests/endpoints/test_menu.py index a4b49f9..0ad5188 100644 --- a/tests/endpoints/test_menu.py +++ b/tests/endpoints/test_menu.py @@ -7,7 +7,7 @@ class PageListAPITestCase(BaseCMSRestTestCase): - def test_get_menu_default(self): + def test_get_menu_no_children(self): """ Test the menu endpoint (/api/{language}/menu/). @@ -30,7 +30,7 @@ def test_get_menu_default(self): kwargs={ "language": "en", "from_level": 0, - "to_level": 100, + "to_level": 0, "extra_inactive": 0, "extra_active": 100, }, From a948e34bc0f9db253459a5e745425920d0688910 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 21:37:09 +0200 Subject: [PATCH 13/22] Update readme --- README.md | 9 +-------- djangocms_rest/views.py | 2 +- 2 files changed, 2 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 250b4d8..d5eb73f 100644 --- a/README.md +++ b/README.md @@ -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` | 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/`
`{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}/ diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index e2b6a68..3b005bd 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -9,7 +9,7 @@ from cms.utils.conf import get_languages from cms.utils.page_permissions import user_can_view_page from menus.base import NavigationNode -from menus.templatetags.menu_tags import ShowMenu, ShowSubMenu, ShowBreadcrumb +from menus.templatetags.menu_tags import ShowBreadcrumb, ShowMenu, ShowSubMenu from rest_framework.exceptions import NotFound From b375e139926e8d8887a9671e838713c23851cd5b Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 22:39:21 +0200 Subject: [PATCH 14/22] Update tests --- tests/endpoints/test_menu.py | 37 ++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) diff --git a/tests/endpoints/test_menu.py b/tests/endpoints/test_menu.py index 0ad5188..624dfd5 100644 --- a/tests/endpoints/test_menu.py +++ b/tests/endpoints/test_menu.py @@ -108,3 +108,40 @@ def test_default_levels(self): self.assertNotEqual(url1, url2) self.assertEqual(results1, results2) + + def test_submenu(self): + # GET + url = reverse( + "submenu", + kwargs={ + "language": "en", + "path": "page-2", + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + results = response.json() + + self.assertEqual(len(results), 2) + + self.assertEqual(results[0]["title"], "page 0") + self.assertEqual(results[1]["title"], "page 1") + + def test_breadcrumbs(self): + # GET + url = reverse( + "breadcrumbs", + kwargs={ + "language": "en", + "path": "page-2/page-0", + }, + ) + response = self.client.get(url) + self.assertEqual(response.status_code, 200) + + results = response.json() + self.assertEqual(len(results), 3) + + self.assertEqual(results[0]["title"], "page 0") + self.assertEqual(results[1]["title"], "page 2") + self.assertEqual(results[2]["title"], "page 0") From d556d8c6cd69d94392824a49f735bac0c3e473d6 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Tue, 2 Sep 2025 22:51:59 +0200 Subject: [PATCH 15/22] fix: No class patching -> better thread-savety --- djangocms_rest/cms_config.py | 38 +++++++++++++++++++++++++++--------- djangocms_rest/utils.py | 34 -------------------------------- djangocms_rest/views.py | 24 +++++++++++------------ 3 files changed, 40 insertions(+), 56 deletions(-) diff --git a/djangocms_rest/cms_config.py b/djangocms_rest/cms_config.py index 8357220..c134491 100644 --- a/djangocms_rest/cms_config.py +++ b/djangocms_rest/cms_config.py @@ -7,7 +7,7 @@ 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 +from menus import base try: @@ -56,13 +56,6 @@ def inner(self, page_content: PageContent, *args, **kwargs): 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"): @@ -71,6 +64,33 @@ def patch_page_menu(menu: type[CMSMenu]): ) +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) + ) + + +def add_api_endpoint(navigation_node: type[base.NavigationNode]): + """Add an API endpoint to the CMSNavigationNode.""" + if not issubclass(navigation_node, NavigationNodeMixin): + navigation_node = type( + f"{navigation_node.__name__}WithAPI", + (navigation_node, NavigationNodeMixin), + {}, + ) + return navigation_node + + class RESTToolbarMixin: """ Mixin to add REST rendering capabilities to the CMS toolbar. @@ -102,5 +122,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) + base.NavigationNode = add_api_endpoint(base.NavigationNode) patch_page_menu(CMSMenu) diff --git a/djangocms_rest/utils.py b/djangocms_rest/utils.py index 7c9798b..0c8ff2f 100644 --- a/djangocms_rest/utils.py +++ b/djangocms_rest/utils.py @@ -2,7 +2,6 @@ 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 @@ -55,36 +54,3 @@ def get_absolute_frontend_url(request: Request, path: str) -> str: 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) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 3b005bd..9b13a21 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -8,7 +8,6 @@ from cms.models import Page, PageContent, Placeholder from cms.utils.conf import get_languages from cms.utils.page_permissions import user_can_view_page -from menus.base import NavigationNode from menus.templatetags.menu_tags import ShowBreadcrumb, ShowMenu, ShowSubMenu @@ -31,7 +30,6 @@ from djangocms_rest.utils import ( get_object, get_site_filtered_queryset, - select_by_api_endpoint, ) from djangocms_rest.views_base import BaseAPIView, BaseListAPIView @@ -298,11 +296,6 @@ def get_menu_structure( tag_instance.kwargs = {} tag_instance.blocks = {} - request.LANGUAGE_CODE = language - request.current_page = get_object(self.site, path) - self.check_object_permissions(request, request.current_page) - context = {"request": request} - if path == "": api_endpoint = reverse("page-root", kwargs={"language": language}) else: @@ -310,12 +303,17 @@ def get_menu_structure( "page-detail", kwargs={"language": language, "path": path} ) - with select_by_api_endpoint(NavigationNode, api_endpoint): - context = tag_instance.get_context( - context=context, - **kwargs, - template=None, - ) + request.api_endpoint = api_endpoint + request.LANGUAGE_CODE = language + request.current_page = get_object(self.site, path) + self.check_object_permissions(request, request.current_page) + context = {"request": request} + + context = tag_instance.get_context( + context=context, + **kwargs, + template=None, + ) return context.get(self.return_key, []) From cbe17ca2c4e5a02f918b1b0024b4d1f28015cdb8 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 4 Sep 2025 10:59:44 +0200 Subject: [PATCH 16/22] fix: Pickling error --- djangocms_rest/cms_config.py | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/djangocms_rest/cms_config.py b/djangocms_rest/cms_config.py index c134491..894a114 100644 --- a/djangocms_rest/cms_config.py +++ b/djangocms_rest/cms_config.py @@ -80,14 +80,14 @@ def is_selected(self, request): ) +class NavigationNodeWithAPI(NavigationNodeMixin, base.NavigationNode): + 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 = type( - f"{navigation_node.__name__}WithAPI", - (navigation_node, NavigationNodeMixin), - {}, - ) + navigation_node = NavigationNodeWithAPI return navigation_node @@ -122,5 +122,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) From e3dfc79e9ddd94530c00636c76feb76d6dd38deb Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Thu, 4 Sep 2025 11:00:56 +0200 Subject: [PATCH 17/22] add comment --- djangocms_rest/cms_config.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/djangocms_rest/cms_config.py b/djangocms_rest/cms_config.py index 894a114..74529d5 100644 --- a/djangocms_rest/cms_config.py +++ b/djangocms_rest/cms_config.py @@ -81,6 +81,8 @@ def is_selected(self, request): class NavigationNodeWithAPI(NavigationNodeMixin, base.NavigationNode): + # NavigationNodeWithAPI must be defined statically at the module level + # to allow it being pickled for cache pass From 7a87be1b442d9016534a7d79329a6bb313399274 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Fri, 5 Sep 2025 22:25:53 +0200 Subject: [PATCH 18/22] Update pytest --- pytest.ini | 1 + 1 file changed, 1 insertion(+) diff --git a/pytest.ini b/pytest.ini index b3df23a..81635c7 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,4 @@ [pytest] DJANGO_SETTINGS_MODULE = tests.settings python_files = tests/test_*.py tests/*/test_*.py +pythonpath = . From 57c6386289117c45ccf9adddd4d5a19c8092a95d Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Sep 2025 19:10:34 +0200 Subject: [PATCH 19/22] fix: Remove id, allow `?preview=false`, fix placeholder preview --- djangocms_rest/serializers/menus.py | 1 - djangocms_rest/views.py | 9 +-------- djangocms_rest/views_base.py | 10 ++++++++-- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py index 7a5313b..1e849cb 100644 --- a/djangocms_rest/serializers/menus.py +++ b/djangocms_rest/serializers/menus.py @@ -6,7 +6,6 @@ class NavigationNodeSerializer(serializers.Serializer): - id = serializers.IntegerField() namespace = serializers.CharField(allow_null=True) title = serializers.CharField() url = serializers.URLField(allow_null=True) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 9b13a21..0c8c447 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -13,7 +13,6 @@ from rest_framework.exceptions import NotFound from rest_framework.pagination import LimitOffsetPagination -from rest_framework.permissions import IsAdminUser from rest_framework.request import Request from rest_framework.response import Response @@ -165,7 +164,6 @@ def get(self, request: Request, language: str, path: str = "") -> Response: class PlaceholderDetailView(BaseAPIView): permission_classes = [IsAllowedPublicLanguage] serializer_class = PlaceholderSerializer - content_manager = "objects" @extend_placeholder_schema def get( @@ -197,7 +195,7 @@ def get( source_model = placeholder.content_type.model_class() source = ( - getattr(source_model, self.content_manager, source_model.objects) + getattr(source_model, self.content_getter, source_model.objects) .filter(pk=placeholder.object_id) .first() ) @@ -220,11 +218,6 @@ def get( return Response(serializer.data) -class PreviewPlaceholderDetailView(PlaceholderDetailView): - content_manager = "admin_manager" - permission_classes = [IsAdminUser] - - class PluginDefinitionView(BaseAPIView): """ API view for retrieving plugin definitions diff --git a/djangocms_rest/views_base.py b/djangocms_rest/views_base.py index 6631314..8b297bf 100644 --- a/djangocms_rest/views_base.py +++ b/djangocms_rest/views_base.py @@ -20,15 +20,20 @@ def site(self): site = getattr(self.request, "site", None) return site if site is not None else get_current_site(self.request) + def _preview_requested(self): + return "preview" in self.request.GET and self.request.GET[ + "preview" + ].lower() not in ("0", "false") + @property def content_getter(self): - if "preview" in self.request.GET: + if self._preview_requested(): return "get_admin_content" return "get_content_obj" def get_permissions(self): permissions = super().get_permissions() - if "preview" in self.request.GET: + if self._preview_requested(): # Require admin access for preview as first check permissions.insert(0, IsAdminUser()) return permissions @@ -46,4 +51,5 @@ class BaseListAPIView(BaseAPIMixin, ListAPIView): """ This is a base class for all list API views. It supports default pagination and sets the allowed methods to GET and OPTIONS. """ + pass From f17ee78afdc6cc4d6e83d9588feb8f193737b68f Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Sep 2025 19:31:24 +0200 Subject: [PATCH 20/22] tests: Add test for preview param --- djangocms_rest/serializers/pages.py | 4 +++- djangocms_rest/views_base.py | 6 +++--- tests/endpoints/test_page_root.py | 8 ++++++++ tests/endpoints/test_placeholders.py | 2 +- 4 files changed, 15 insertions(+), 5 deletions(-) diff --git a/djangocms_rest/serializers/pages.py b/djangocms_rest/serializers/pages.py index 6427e3b..f616e47 100644 --- a/djangocms_rest/serializers/pages.py +++ b/djangocms_rest/serializers/pages.py @@ -37,7 +37,9 @@ class BasePageSerializer(serializers.Serializer): class BasePageContentMixin: @property def is_preview(self): - return "preview" in self.request.GET + return "preview" in self.request.GET and self.request.GET.get( + "preview", "" + ).lower() not in ("0", "false") def get_base_representation(self, page_content: PageContent) -> dict: request = getattr(self, "request", None) diff --git a/djangocms_rest/views_base.py b/djangocms_rest/views_base.py index 8b297bf..b86e706 100644 --- a/djangocms_rest/views_base.py +++ b/djangocms_rest/views_base.py @@ -21,9 +21,9 @@ def site(self): return site if site is not None else get_current_site(self.request) def _preview_requested(self): - return "preview" in self.request.GET and self.request.GET[ - "preview" - ].lower() not in ("0", "false") + return "preview" in self.request.GET and self.request.GET.get( + "preview", "" + ).lower() not in ("0", "false") @property def content_getter(self): diff --git a/tests/endpoints/test_page_root.py b/tests/endpoints/test_page_root.py index 3c6beaa..a507a1f 100644 --- a/tests/endpoints/test_page_root.py +++ b/tests/endpoints/test_page_root.py @@ -28,6 +28,14 @@ def test_get(self): page = response.json() self.assertFalse(response.json().get("is_preview")) + # GET with ?preview=false + response = self.client.get( + reverse("page-root", kwargs={"language": "en"}) + "?preview=false" + ) + self.assertEqual(response.status_code, 200) + page = response.json() + self.assertFalse(response.json().get("is_preview")) + # Data & Type Validation for field, expected_type in type_checks.items(): self.assertIn(field, page, f"Field {field} is missing") diff --git a/tests/endpoints/test_placeholders.py b/tests/endpoints/test_placeholders.py index 0666855..ef71851 100644 --- a/tests/endpoints/test_placeholders.py +++ b/tests/endpoints/test_placeholders.py @@ -179,7 +179,7 @@ def test_get(self): "slot": "content", }, ) - + "?preview" + + "?preview=true" ) self.assertEqual(response.status_code, 403) From e8c18d145eb669a14221d5f99edca545893908e3 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Sep 2025 20:40:47 +0200 Subject: [PATCH 21/22] fix: Remove id from menu endpoint --- djangocms_rest/serializers/menus.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/djangocms_rest/serializers/menus.py b/djangocms_rest/serializers/menus.py index 1e849cb..e8dcc1d 100644 --- a/djangocms_rest/serializers/menus.py +++ b/djangocms_rest/serializers/menus.py @@ -30,10 +30,11 @@ def get_children(self, obj: NavigationNode) -> list[dict]: 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), + "api_endpoint": get_absolute_frontend_url( + self.request, getattr(obj, "api_endpoint", None) + ), "visible": obj.visible, "selected": obj.selected or obj.attr.get("is_home", False) From 98033ee73e75c611733d47e537af8460308231f9 Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Mon, 15 Sep 2025 22:25:12 +0200 Subject: [PATCH 22/22] fix: placeholder preview --- djangocms_rest/views.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/djangocms_rest/views.py b/djangocms_rest/views.py index 0c8c447..b3f54c0 100644 --- a/djangocms_rest/views.py +++ b/djangocms_rest/views.py @@ -194,8 +194,9 @@ def get( raise NotFound() source_model = placeholder.content_type.model_class() + content_manager = "admin_manager" if self._preview_requested() else "content" source = ( - getattr(source_model, self.content_getter, source_model.objects) + getattr(source_model, content_manager, source_model.objects) .filter(pk=placeholder.object_id) .first() )