Skip to content
86 changes: 86 additions & 0 deletions djangocms_rest/schemas.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
"""OpenAPI schema generation utilities for djangocms-rest."""

try:
from drf_spectacular.openapi import AutoSchema
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, OpenApiResponse, extend_schema

from djangocms_rest.serializers.menus import NavigationNodeSerializer

class MenuSchema(AutoSchema):
"""
Custom schema generator that sets operation_id from URL name stored in view.
This is needed to create distinct operation_ids for each menu endpoint.
Adds _retrieve to the operation_id to match drf-spectacular's default pattern.
"""

def get_operation_id(self):
"""Override to use URL name stored in view as operation_id."""
try:
url_name = getattr(self.view, "_url_name", None)
if not url_name and hasattr(self.view, "__class__"):
url_name = getattr(self.view.__class__, "_url_name", None)

if url_name:
return url_name.replace("-", "_") + "_retrieve"

# Fallback to default
return super().get_operation_id()
except Exception:
return super().get_operation_id()

def create_view_with_url_name(view_class, url_name):
"""Create a view instance with URL name stored for schema generation."""

class ViewWithUrlName(view_class):
_url_name = url_name

return ViewWithUrlName.as_view()

def menu_schema_class(view_class):
"""Decorator to apply MenuSchema to a view class."""
view_class.schema = MenuSchema()
return view_class

def method_schema_decorator(method):
"""
Decorator for adding OpenAPI schema to a method.
Needed to force the schema to use many=True for NavigationNodeSerializer.
"""
return extend_schema(responses=OpenApiResponse(response=NavigationNodeSerializer(many=True)))(method)

extend_placeholder_schema = extend_schema(
parameters=[
OpenApiParameter(
name="html",
type=OpenApiTypes.INT,
location="query",
description="Set to 1 to include HTML rendering in response",
required=False,
enum=[1],
),
OpenApiParameter(
name="preview",
type=OpenApiTypes.BOOL,
location="query",
description="Set to true to preview unpublished content (admin access required)",
required=False,
),
]
)

except ImportError:

def method_schema_decorator(method):
return method

def menu_schema_class(view_class):
return view_class

def create_view_with_url_name(view_class, url_name):
"""No-op when drf-spectacular is not available."""
return view_class.as_view()

def extend_placeholder_schema(func):
"""No-op when drf-spectacular is not available."""
return func
63 changes: 32 additions & 31 deletions djangocms_rest/urls.py
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
from django.urls import path

from . import views
from .schemas import create_view_with_url_name


urlpatterns = [
Expand Down Expand Up @@ -37,85 +38,85 @@
),
path("plugins/", views.PluginDefinitionView.as_view(), name="plugin-list"),
# Menu endpoints
path("<slug:language>/menu/", views.MenuView.as_view(), name="menu"),
path("<slug:language>/menu/", create_view_with_url_name(views.MenuView, "menu"), name="menu"),
path(
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
views.MenuView.as_view(),
name="menu",
create_view_with_url_name(views.MenuView, "menu-levels"),
name="menu-levels",
),
path(
"<slug:language>/menu/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
views.MenuView.as_view(),
name="menu",
create_view_with_url_name(views.MenuView, "menu-levels-path"),
name="menu-levels-path",
),
path(
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/",
views.MenuView.as_view(),
name="menu",
create_view_with_url_name(views.MenuView, "menu-root-levels"),
name="menu-root-levels",
),
path(
"<slug:language>/menu/<slug:root_id>/<int:from_level>/<int:to_level>/<int:extra_inactive>/<int:extra_active>/<path:path>/",
views.MenuView.as_view(),
name="menu",
create_view_with_url_name(views.MenuView, "menu-root-levels-path"),
name="menu-root-levels-path",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-nephews-path"),
name="submenu-levels-root-nephews-path",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/<int:nephews>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-nephews"),
name="submenu-levels-root-nephews",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels-root-path"),
name="submenu-levels-root-path",
),
path(
"<slug:language>/submenu/<int:levels>/<int:root_level>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels-root"),
name="submenu-levels-root",
),
path(
"<slug:language>/submenu/<int:levels>/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels-path"),
name="submenu-levels-path",
),
path(
"<slug:language>/submenu/<int:levels>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-levels"),
name="submenu-levels",
),
path(
"<slug:language>/submenu/<path:path>/",
views.SubMenuView.as_view(),
name="submenu",
create_view_with_url_name(views.SubMenuView, "submenu-path"),
name="submenu-path",
),
path(
"<slug:language>/submenu/",
views.SubMenuView.as_view(),
create_view_with_url_name(views.SubMenuView, "submenu"),
name="submenu",
),
path(
"<slug:language>/breadcrumbs/<int:start_level>/<path:path>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-level-path"),
name="breadcrumbs-level-path",
),
path(
"<slug:language>/breadcrumbs/<int:start_level>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-level"),
name="breadcrumbs-level",
),
path(
"<slug:language>/breadcrumbs/<path:path>/",
views.BreadcrumbView.as_view(),
name="breadcrumbs",
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs-path"),
name="breadcrumbs-path",
),
path(
"<slug:language>/breadcrumbs/",
views.BreadcrumbView.as_view(),
create_view_with_url_name(views.BreadcrumbView, "breadcrumbs"),
name="breadcrumbs",
),
]
75 changes: 5 additions & 70 deletions djangocms_rest/views.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

from typing import Any, ParamSpec, TypeVar
from collections.abc import Callable
from typing import Any
from django.contrib.sites.shortcuts import get_current_site
from django.urls import reverse
from django.utils.functional import lazy
Expand Down Expand Up @@ -33,57 +32,7 @@
get_site_filtered_queryset,
)
from djangocms_rest.views_base import BaseAPIView, BaseListAPIView

P = ParamSpec("P")
T = TypeVar("T")

try:
from drf_spectacular.types import OpenApiTypes
from drf_spectacular.utils import OpenApiParameter, extend_schema

extend_placeholder_schema = extend_schema(
parameters=[
OpenApiParameter(
name="html",
type=OpenApiTypes.INT,
location="query",
description="Set to 1 to include HTML rendering in response",
required=False,
enum=[1],
),
OpenApiParameter(
name="preview",
type=OpenApiTypes.BOOL,
location="query",
description="Set to true to preview unpublished content (admin access required)",
required=False,
),
]
)

except ImportError: # pragma: no cover

class OpenApiTypes:
BOOL = "boolean"
INT = "integer"

class OpenApiParameter: # pragma: no cover
QUERY = "query"
PATH = "path"
HEADER = "header"
COOKIE = "cookie"

def __init__(self, *args, **kwargs):
pass

def extend_schema(*_args, **_kwargs): # pragma: no cover
def _decorator(obj: T) -> T:
return obj

return _decorator

def extend_placeholder_schema(func: Callable[P, T]) -> Callable[P, T]:
return func
from djangocms_rest.schemas import extend_placeholder_schema, menu_schema_class


# Generate the plugin definitions once at module load time
Expand Down Expand Up @@ -258,30 +207,14 @@ def get(self, request: Request) -> Response:
return Response(definitions)


try:
from drf_spectacular.utils import extend_schema, OpenApiResponse

def method_schema_decorator(method):
"""
Decorator for adding OpenAPI schema to a method.
Needed to force the schema to use many=True for NavigationNodeSerializer.
"""
return extend_schema(responses=OpenApiResponse(response=NavigationNodeSerializer(many=True)))(method)

except ImportError: # pragma: no cover

def method_schema_decorator(method): # pragma: no cover
return method # pragma: no cover


@menu_schema_class
class MenuView(BaseAPIView):
permission_classes = [IsAllowedPublicLanguage]
serializer_class = NavigationNodeSerializer

tag = ShowMenu
return_key = "children"

@method_schema_decorator
def get(
self,
request: Request,
Expand Down Expand Up @@ -352,6 +285,7 @@ def get_menu_structure(
return result


@menu_schema_class
class SubMenuView(MenuView):
tag = ShowSubMenu

Expand All @@ -361,6 +295,7 @@ def populate_defaults(self, kwargs: dict[str, Any]) -> None:
kwargs.setdefault("nephews", 100)


@menu_schema_class
class BreadcrumbView(MenuView):
tag = ShowBreadcrumb
return_key = "ancestors"
Expand Down
14 changes: 7 additions & 7 deletions tests/endpoints/test_menu.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ def test_get_menu_no_children(self):

# GET
url = reverse(
"menu",
"menu-levels",
kwargs={
"language": "en",
"from_level": 0,
Expand Down Expand Up @@ -68,7 +68,7 @@ def test_get_menu_with_children(self):

# GET
url = reverse(
"menu",
"menu-levels-path",
kwargs={
"language": "en",
"path": "page-2",
Expand All @@ -94,7 +94,7 @@ def test_default_levels(self):
kwargs={"language": "en"},
)
url2 = reverse(
"menu",
"menu-levels",
kwargs={
"language": "en",
"from_level": 0,
Expand All @@ -111,14 +111,14 @@ def test_default_levels(self):

def test_non_existing_root_id(self):
url = reverse(
"menu",
"menu-root-levels",
kwargs={
"language": "en",
"root_id": "I_DO_NOT_EXIST",
"from_level": 0,
"to_level": 100,
"extra_inactive": 0,
"extra_active": 100,
"root_id": "I_DO_NOT_EXIST",
},
)
response = self.client.get(url)
Expand All @@ -127,7 +127,7 @@ def test_non_existing_root_id(self):
def test_submenu(self):
# GET
url = reverse(
"submenu",
"submenu-path",
kwargs={
"language": "en",
"path": "page-2",
Expand All @@ -145,7 +145,7 @@ def test_submenu(self):
def test_breadcrumbs(self):
# GET
url = reverse(
"breadcrumbs",
"breadcrumbs-path",
kwargs={
"language": "en",
"path": "page-2/page-0",
Expand Down
Loading