Skip to content

Conversation

fsbraun
Copy link
Member

@fsbraun fsbraun commented Aug 29, 2025

Fixes #4

Summary by Sourcery

Add menu endpoints for navigation and unify preview handling via query parameters. Introduce serializers and utilities for menu structures, enhance CMS menu nodes with API endpoints, refactor base views for preview logic, rename routes accordingly, and update tests and documentation.

New Features:

  • Add MenuView endpoint with configurable levels and path parameters to fetch menu structure
  • Introduce NavigationNodeSerializer and list serializer for menu nodes
  • Add select_by_api_endpoint context manager for dynamic node selection logic
  • Extend CMSMenu and NavigationNode to include API endpoint attributes
  • Simplify preview support via query parameter into BaseAPIMixin (remove dedicated preview views)

Enhancements:

  • Rename pages-root routes to pages and consolidate preview endpoints under query parameters
  • Refactor BaseAPIMixin to manage content retrieval strategy and permissions based on preview flag
  • Remove PreviewPageContentSerializer and infer preview state in BasePageContentMixin

Documentation:

  • Update README to reflect new menu and renamed page endpoints

Tests:

  • Add comprehensive tests for the new menu endpoint
  • Update existing endpoint tests to use ?preview query parameter and updated route names

Copy link
Contributor

sourcery-ai bot commented Aug 29, 2025

Reviewer's Guide

This PR adds REST API support for CMS menus by introducing a parameterized MenuView and related serializers, refactors base API views for unified preview handling, extends utility functions for API-based node selection, patches CMS navigation nodes to include API endpoints, updates URL routing for menu and preview patterns, and brings tests and documentation in line with these changes.

File-Level Changes

Change Details Files
Introduce MenuView and menu endpoints for structured navigation
  • Removed PreviewPage* view classes and content_getter overrides
  • Added MenuView with GET method and get_menu_structure logic
  • Parameterize menu endpoints with language, path, and level arguments
djangocms_rest/views.py
Refactor API base views with preview mixin
  • Created BaseAPIMixin to manage content_getter property and preview permissions
  • Updated BaseAPIView and BaseListAPIView to inherit from BaseAPIMixin
djangocms_rest/views_base.py
Enhance serializers for page previews and menu nodes
  • Replaced PreviewPageContentSerializer with is_preview property in BasePageContentMixin
  • Switched placeholder lookup to use page_content.placeholders
  • Added NavigationNodeSerializer and list serializer for menu nodes
djangocms_rest/serializers/pages.py
djangocms_rest/serializers/menus.py
Extend utility functions for URL and node selection
  • Allow None in get_absolute_frontend_url and use request host for domain
  • Introduced select_by_api_endpoint context manager to patch NavigationNode.is_selected
djangocms_rest/utils.py
Patch CMS navigation nodes to expose API endpoints
  • Added methods to attach api_endpoint on NavigationNode
  • Patched CMSMenu.get_menu_node_for_page_content to inject API endpoints
djangocms_rest/cms_config.py
Update URL routing for menu and preview endpoints
  • Replaced preview-specific URL patterns with query-param based preview
  • Registered /menu endpoints with configurable path and parameters
djangocms_rest/urls.py
Align tests with new menu and preview behavior
  • Refactored existing endpoint tests to use ?preview query parameter
  • Added tests for menu endpoint and utility functions
tests/endpoints/test_page_root.py
tests/endpoints/test_page_list.py
tests/endpoints/test_page_tree_list.py
tests/endpoints/test_placeholders.py
tests/endpoints/test_menu.py
tests/test_utils.py
tests/test_plugin_renderer.py
tests/base.py
Refresh documentation to reflect updated endpoints
  • Updated README.md to replace /pages-root with /pages and document menu endpoints
README.md

Assessment against linked issues

Issue Objective Addressed Explanation
#4 Implement a REST API endpoint that exposes the CMS menu tree structure, allowing retrieval of hierarchical menu data based on the page tree or custom menu nodes.
#4 Support configuration of menu endpoints with parameters such as path, depth, and levels, enabling flexible retrieval of menu hierarchies for different use cases.
#4 Expose menu nodes via the REST API in a way that supports both page tree-based menus and custom plugin-based hierarchical menus (e.g., via djangocms-aliases), including serialization of navigation nodes and their children.

Possibly linked issues


Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

codecov bot commented Aug 29, 2025

Codecov Report

❌ Patch coverage is 96.69421% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 91.44%. Comparing base (b29d423) to head (b29b7e7).

Files with missing lines Patch % Lines
djangocms_rest/cms_config.py 84.00% 2 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #49      +/-   ##
==========================================
+ Coverage   90.97%   91.44%   +0.47%     
==========================================
  Files          17       18       +1     
  Lines         720      807      +87     
  Branches       81       87       +6     
==========================================
+ Hits          655      738      +83     
- Misses         42       44       +2     
- Partials       23       25       +2     

☔ View full report in Codecov by Sentry.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fsbraun fsbraun marked this pull request as ready for review September 2, 2025 18:48
Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and they look great!

Prompt for AI Agents
Please address the comments from this code review:
## Individual Comments

### Comment 1
<location> `djangocms_rest/serializers/menus.py:31` </location>
<code_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),
+        }
+
</code_context>

<issue_to_address>
The selected field logic may be confusing due to operator precedence.

Add parentheses to the 'selected' field expression to ensure the logic is clear and operator precedence does not cause unintended behavior.
</issue_to_address>

### Comment 2
<location> `djangocms_rest/utils.py:60` </location>
<code_context>
+@contextmanager
</code_context>

<issue_to_address>
Patching class attributes in a context manager may cause thread-safety issues.

Consider instance-level patching or alternative methods to ensure thread safety in concurrent scenarios.
</issue_to_address>

### Comment 3
<location> `tests/endpoints/test_menu.py:9` </location>
<code_context>
+from cms.models import Page
+
+
+class PageListAPITestCase(BaseCMSRestTestCase):
+    def test_get_menu_default(self):
+        """
</code_context>

<issue_to_address>
Consider adding tests for invalid input and error conditions for the menu endpoint.

Please add tests for invalid parameters, such as negative levels, non-existent paths, and malformed requests, to verify error handling and status code responses.
</issue_to_address>

### Comment 4
<location> `tests/endpoints/test_menu.py:60` </location>
<code_context>
+        self.assertFalse(results[1]["selected"])
+        self.assertFalse(results[2]["selected"])
+
+    def test_get_menu_with_children(self):
+        """
+        Test the menu endpoint (/api/{language}/menu/{path}/) with child pages.
</code_context>

<issue_to_address>
Test coverage for deeply nested children in the menu structure is missing.

Please add a test case with a multi-level page hierarchy to verify recursive serialization of nested children.

Suggested implementation:

```python
    def test_get_menu_with_deeply_nested_children(self):
        """
        Test the menu endpoint (/api/{language}/menu/{path}/) with deeply nested child pages.

        Verifies:
        - Recursive serialization of nested children
        - All levels have correct titles and structure
        """

        # Create a multi-level page hierarchy: root -> child -> grandchild
        root_page = Page.objects.create(title="Root", slug="root", parent=None, language="en")
        child_page = Page.objects.create(title="Child", slug="child", parent=root_page, language="en")
        grandchild_page = Page.objects.create(title="Grandchild", slug="grandchild", parent=child_page, language="en")

        url = reverse(
            "menu",
            kwargs={

```

```python
        url = reverse(
            "menu",
            kwargs={
                "language": "en",
                "path": "root",
            }
        )
        response = self.client.get(url)
        assert response.status_code == 200
        results = response.json()

        # Root should have one child
        assert results[0]["title"] == "Root"
        assert len(results[0]["children"]) == 1
        assert results[0]["children"][0]["title"] == "Child"

        # Child should have one child (grandchild)
        child_result = results[0]["children"][0]
        assert len(child_result["children"]) == 1
        assert child_result["children"][0]["title"] == "Grandchild"

        # Grandchild should have no children
        grandchild_result = child_result["children"][0]
        assert grandchild_result["children"] == []

```
</issue_to_address>

### Comment 5
<location> `tests/endpoints/test_menu.py:56` </location>
<code_context>
+        self.assertEqual(results[2]["children"], [])
+
+        # 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):
</code_context>

<issue_to_address>
Missing test for menu endpoint with a non-existent language code.

Please add a test that verifies a 404 response when the menu endpoint is requested with an invalid language code.
</issue_to_address>

### Comment 6
<location> `djangocms_rest/cms_config.py:47` </location>
<code_context>
     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)
</code_context>

<issue_to_address>
Consider replacing patch functions with a subclass and method override to improve code clarity and maintainability.

Consider replacing the free‐standing patch functions with a simple subclass + override. This keeps all the same behavior but is much easier to trace:

```python
# new subclass, no more patch_* helpers
from cms.cms_menus import CMSMenu

class APICMSMenu(CMSMenu):
    def get_menu_node_for_page_content(self, page_content, *args, **kwargs):
        node = super().get_menu_node_for_page_content(page_content, *args, **kwargs)
        node.api_endpoint = get_page_api_endpoint(
            page_content.page,
            page_content.language,
        )
        return node
```

Then wire it up in your AppConfig:

```python
class RESTCMSConfig(CMSAppConfig):
    cms_enabled = True
    cms_toolbar_mixin = RESTToolbarMixin
    cms_menus = [APICMSMenu]      # ← use our subclass here

    def ready(self):
        super().ready()
        Page.add_to_class("get_api_endpoint", get_page_api_endpoint)
        if File:
            File.add_to_class("get_api_endpoint", get_file_api_endpoint)
```

This drops `patch_get_menu_node_for_page_content`, `patch_page_menu` and `add_api_endpoint` while preserving identical behavior.
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

from cms.models import Page


class PageListAPITestCase(BaseCMSRestTestCase):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Consider adding tests for invalid input and error conditions for the menu endpoint.

Please add tests for invalid parameters, such as negative levels, non-existent paths, and malformed requests, to verify error handling and status code responses.

self.assertFalse(results[1]["selected"])
self.assertFalse(results[2]["selected"])

def test_get_menu_with_children(self):
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Test coverage for deeply nested children in the menu structure is missing.

Please add a test case with a multi-level page hierarchy to verify recursive serialization of nested children.

Suggested implementation:

    def test_get_menu_with_deeply_nested_children(self):
        """
        Test the menu endpoint (/api/{language}/menu/{path}/) with deeply nested child pages.

        Verifies:
        - Recursive serialization of nested children
        - All levels have correct titles and structure
        """

        # Create a multi-level page hierarchy: root -> child -> grandchild
        root_page = Page.objects.create(title="Root", slug="root", parent=None, language="en")
        child_page = Page.objects.create(title="Child", slug="child", parent=root_page, language="en")
        grandchild_page = Page.objects.create(title="Grandchild", slug="grandchild", parent=child_page, language="en")

        url = reverse(
            "menu",
            kwargs={
        url = reverse(
            "menu",
            kwargs={
                "language": "en",
                "path": "root",
            }
        )
        response = self.client.get(url)
        assert response.status_code == 200
        results = response.json()

        # Root should have one child
        assert results[0]["title"] == "Root"
        assert len(results[0]["children"]) == 1
        assert results[0]["children"][0]["title"] == "Child"

        # Child should have one child (grandchild)
        child_result = results[0]["children"][0]
        assert len(child_result["children"]) == 1
        assert child_result["children"][0]["title"] == "Grandchild"

        # Grandchild should have no children
        grandchild_result = child_result["children"][0]
        assert grandchild_result["children"] == []

Comment on lines +56 to +58
self.assertTrue(results[0]["selected"])
self.assertFalse(results[1]["selected"])
self.assertFalse(results[2]["selected"])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (testing): Missing test for menu endpoint with a non-existent language code.

Please add a test that verifies a 404 response when the menu endpoint is requested with an invalid language code.

from django.contrib.sites.models import Site
from rest_framework.reverse import reverse

from tests.base import BaseCMSRestTestCase
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (code-quality): Don't import test modules. (dont-import-test-modules)

ExplanationDon't import test modules.

Tests should be self-contained and don't depend on each other.

If a helper function is used by multiple tests,
define it in a helper module,
instead of importing one test from the other.

@fsbraun fsbraun requested a review from metaforx September 2, 2025 20:56
@metaforx

This comment was marked as resolved.

@metaforx
Copy link
Collaborator

metaforx commented Sep 5, 2025

GET http://127.0.0.1:8080/api/en/breadcrumbs/

Hide id
Is id from response a page id? If this is a page id i would not expose it, similar to pages response where we also hide this attribute.

{
        "id": 4, ❓
        "title": "test",
        "url": "http://127.0.0.1:8080/",
        "api_endpoint": "http://127.0.0.1:8080/api/en/pages/",
        "visible": true,
        "selected": false,
        "attr": {
            "is_page": true,
            "soft_root": false,
            "auth_required": false,
            "reverse_id": null,
            "is_home": true,
            "visible_for_authenticated": true,
            "visible_for_anonymous": true,
            "navigation_extenders": []
        },

@metaforx
Copy link
Collaborator

metaforx commented Sep 5, 2025

We likely shoud add type checking for breadcrumb endpoints, similar to pages.

#types.py
# API Response Type Definitions
PAGE_META_FIELD_TYPES = {
    "title": str,
    "page_title": str,
    "menu_title": str,
    "meta_description": (str, type(None)),
    "redirect": (str, type(None)),
    "in_navigation": bool,
    "soft_root": bool,
    "template": str,
    "xframe_options": str,
    "limit_visibility_in_menu": (bool, type(None)),
    "language": str,
    "path": str,
    "absolute_url": str,
    "is_home": bool,
    "login_required": bool,
    "languages": list,
    "is_preview": bool,
    "application_namespace": (str, type(None)),
    "creation_date": (str, "datetime"),
    "changed_date": (str, "datetime"),
}

# test_plugin_renderer.py
...
        # Data & Type Validation
        for page in results:
            for field, expected_type in type_checks.items():
                assert_field_types(
                    self,
                    page,
                    field,
                    expected_type,
                )
                ```

@metaforx
Copy link
Collaborator

metaforx commented Sep 5, 2025

@fsbraun:

Suggest stricter type checks:

  • Add type definition and include in tests
  • Add namespace to response, because it might be missing in some cases (tests) and this will cause type checks in the frontend to fail. Every attribute has to be present in the schema.

I would commit this to PR, but I think policy prevents it, which is fine with me.

This has to be added to all test cases, but only once to the menu, as we reuse it in breadcrumbs.

#types.py
MENU_FIELD_TYPES = {
    "title": str,
    # "menu_title": str, #missing?
    "namespace": (str, type(None)),  # application_namespace?
    "url": str,  # absolute_url?
    "api_endpoint": str,
    "visible": bool,
    "selected": bool,
    "attr": dict,
    "level": (int, type(None)),
    "children": (list, type(None)),
}

#types_menu.py
type_checks = MENU_FIELD_TYPES
...
# Data & Type Validation
for menu_item in results:
    for field, expected_type in type_checks.items():
        assert_field_types(
            self,
            menu_item,
            field,
            expected_type,
        )
 
#menus.py
        return {
            "id": obj.id,
            "title": obj.title,
            "namespace": obj.namespace if hasattr(obj, "namespace") else None,
            "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),
        }

@metaforx
Copy link
Collaborator

metaforx commented Sep 5, 2025

@fsbraun The only issue with ?preview is with Placeholders:

http://localhost:8080/admin/cms/placeholder/object/5/edit/24/

This shoud be retrievable via http://127.0.0.1:8080/api/en/placeholders/5/24/content/?preview=true with the session cookie set. This is working for pages but not for placeholders.

Retrieving unpublished objects via the placeholders endpoint using the ?preview flag is working. for you?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Feature: Menu Tree REST API
2 participants