diff --git a/djangocms_rest/plugin_rendering.py b/djangocms_rest/plugin_rendering.py
index aba7d5a..fbb0c5a 100644
--- a/djangocms_rest/plugin_rendering.py
+++ b/djangocms_rest/plugin_rendering.py
@@ -1,11 +1,10 @@
import json
-from typing import Any, TypeVar
+from typing import Any
from collections.abc import Iterable
from django.contrib.sites.shortcuts import get_current_site
from django.core.exceptions import ValidationError
from django.core.validators import URLValidator
-from django.db import models
from django.utils.html import escape, mark_safe
from cms.models import Placeholder
@@ -13,52 +12,23 @@
from cms.utils.plugins import get_plugins
from djangocms_rest.serializers.placeholders import PlaceholderSerializer
-from djangocms_rest.serializers.plugins import GenericPluginSerializer, base_exclude
+from djangocms_rest.serializers.plugins import (
+ get_auto_model_serializer,
+ resolve_plugin_serializer,
+)
from djangocms_rest.serializers.utils.cache import (
get_placeholder_rest_cache,
set_placeholder_rest_cache,
)
-ModelType = TypeVar("ModelType", bound=models.Model)
-
-
-def get_auto_model_serializer(model_class: type[ModelType]) -> type:
- """
- Build (once) a generic ModelSerializer subclass that excludes
- common CMS bookkeeping fields.
- """
-
- opts = model_class._meta
- real_fields = {f.name for f in opts.get_fields()}
- exclude = tuple(base_exclude & real_fields)
-
- meta_class = type(
- "Meta",
- (),
- {
- "model": model_class,
- "exclude": exclude,
- },
- )
- return type(
- f"{model_class.__name__}AutoSerializer",
- (GenericPluginSerializer,),
- {
- "Meta": meta_class,
- },
- )
-
-
-def serialize_cms_plugin(
- instance: Any | None, context: dict[str, Any]
-) -> dict[str, Any] | None:
- if not instance or not hasattr(instance, "get_plugin_instance"):
+def serialize_cms_plugin(instance: Any | None, context: dict[str, Any]) -> dict[str, Any] | None:
+ if not instance or not hasattr(instance, "get_plugin_instance"): # pragma: no cover
return None
plugin_instance, plugin = instance.get_plugin_instance()
model_cls = plugin_instance.__class__
- serializer_cls = getattr(plugin, "serializer_class", None)
+ serializer_cls = resolve_plugin_serializer(plugin, model_cls)
serializer_cls = serializer_cls or get_auto_model_serializer(model_cls)
plugin.__class__.serializer_class = serializer_cls
@@ -72,10 +42,7 @@ def serialize_cms_plugin(
)
# Template for a collapsable object/list
-OBJ_TEMPLATE = (
- "{open}
"
- '{value}
{close}'
-)
+OBJ_TEMPLATE = "{open}
" '{value}
{close}'
# Tempalte for a non-collasable object/list
FIXED_TEMPLATE = '{open}
".format(
placeholder=placeholder.slot,
language=language,
@@ -265,9 +224,7 @@ def render_plugins(
def get_plugins_and_placeholder_lot(
self, placeholder, language, context, editable=False, template=None
) -> Iterable[str]:
- yield from super().render_plugins(
- placeholder, language, context, editable=editable, template=template
- )
+ yield from super().render_plugins(placeholder, language, context, editable=editable, template=template)
def serialize_placeholder(self, placeholder, context, language, use_cache=True):
context.update({"request": self.request})
@@ -312,9 +269,7 @@ def serialize_placeholder(self, placeholder, context, language, use_cache=True):
return plugin_content
- def serialize_plugins(
- self, placeholder: Placeholder, language: str, context: dict
- ) -> list:
+ def serialize_plugins(self, placeholder: Placeholder, language: str, context: dict) -> list:
plugins = get_plugins(
self.request,
placeholder=placeholder,
@@ -327,9 +282,7 @@ def serialize_children(child_plugins):
for child_plugin in child_plugins:
child_content = serialize_cms_plugin(child_plugin, context)
if getattr(child_plugin, "child_plugin_instances", None):
- child_content["children"] = serialize_children(
- child_plugin.child_plugin_instances
- )
+ child_content["children"] = serialize_children(child_plugin.child_plugin_instances)
if child_content:
children_list.append(child_content)
return children_list
@@ -338,10 +291,7 @@ def serialize_children(child_plugins):
for plugin in plugins:
plugin_content = serialize_cms_plugin(plugin, context)
if getattr(plugin, "child_plugin_instances", None):
- plugin_content["children"] = serialize_children(
- plugin.child_plugin_instances
- )
+ plugin_content["children"] = serialize_children(plugin.child_plugin_instances)
if plugin_content:
results.append(plugin_content)
return results
-
diff --git a/djangocms_rest/serializers/alias.py b/djangocms_rest/serializers/alias.py
new file mode 100644
index 0000000..777fd49
--- /dev/null
+++ b/djangocms_rest/serializers/alias.py
@@ -0,0 +1,48 @@
+from typing import Any
+
+from djangocms_alias.models import AliasPlugin
+from rest_framework import serializers
+
+from djangocms_rest.serializers.plugins import GenericPluginSerializer, base_exclude
+
+
+class AliasInlineSerializer(GenericPluginSerializer):
+ content = serializers.SerializerMethodField()
+
+ class Meta:
+ model = AliasPlugin
+ exclude = tuple(base_exclude)
+
+ def get_content(self, instance: AliasPlugin) -> list[dict[str, Any]]:
+ request = self.request
+ language = getattr(instance, "language", None)
+ if not request or not language: # pragma: no cover
+ return []
+
+ alias_stack = getattr(request, "_rest_alias_stack", None)
+ if alias_stack is None:
+ alias_stack = []
+ request._rest_alias_stack = alias_stack
+
+ if instance.alias_id in alias_stack:
+ return []
+
+ alias_stack.append(instance.alias_id)
+ try:
+ placeholder = instance.alias.get_placeholder(
+ language=language,
+ show_draft_content=bool(getattr(request, "_preview_mode", False)),
+ )
+ if not placeholder: # pragma: no cover
+ return []
+
+ from djangocms_rest.plugin_rendering import RESTRenderer
+
+ renderer = RESTRenderer(request=request)
+ return renderer.serialize_plugins(
+ placeholder=placeholder,
+ language=language,
+ context=self.context,
+ )
+ finally:
+ alias_stack.pop()
diff --git a/djangocms_rest/serializers/plugins.py b/djangocms_rest/serializers/plugins.py
index 856d79d..e62958c 100644
--- a/djangocms_rest/serializers/plugins.py
+++ b/djangocms_rest/serializers/plugins.py
@@ -45,9 +45,7 @@ def serialize_fk(
# Second choice: Use DRF naming conventions to build the default API URL for the related model
model_name = related_model._meta.model_name
try:
- return get_absolute_frontend_url(
- request, reverse(f"{model_name}-detail", args=(pk,))
- )
+ return get_absolute_frontend_url(request, reverse(f"{model_name}-detail", args=(pk,)))
except NoReverseMatch:
pass
@@ -132,11 +130,7 @@ def to_representation(self, instance: CMSPlugin):
request,
field.related_model,
getattr(instance, f"{field.name}_id"),
- obj=(
- getattr(instance, field.name)
- if field.is_cached(instance)
- else None
- ),
+ obj=(getattr(instance, field.name) if field.is_cached(instance) else None),
)
elif isinstance(field, JSON_FIELDS) and ret.get(field.name):
# If the field is a subclass of JSONField, serialize its value directly
@@ -144,14 +138,56 @@ def to_representation(self, instance: CMSPlugin):
return ret
+def get_plugin_serializer_overrides() -> dict[str, type]:
+ """Return built-in serializer overrides keyed by model label."""
+ overrides: dict[str, type] = {}
+ if apps.is_installed("djangocms_alias"): # pragma: no cover
+ from djangocms_rest.serializers.alias import AliasInlineSerializer
+
+ overrides["djangocms_alias.AliasPlugin"] = AliasInlineSerializer
+ return overrides
+
+
+def resolve_plugin_serializer(plugin: Any, model_class: type[Model]) -> type | None:
+ """Resolve serializer class using plugin serializer first, then central fallback."""
+ overrides = get_plugin_serializer_overrides()
+ model_label = f"{model_class._meta.app_label}.{model_class.__name__}"
+ return getattr(plugin, "serializer_class", None) or overrides.get(model_label)
+
+
+def get_auto_model_serializer(model_class: type[Model]) -> type:
+ """
+ Build (once) a generic ModelSerializer subclass that excludes
+ common CMS bookkeeping fields.
+ """
+
+ opts = model_class._meta
+ real_fields = {field.name for field in opts.get_fields()}
+ exclude = tuple(base_exclude & real_fields)
+
+ meta_class = type(
+ "Meta",
+ (),
+ {
+ "model": model_class,
+ "exclude": exclude,
+ },
+ )
+ return type(
+ f"{model_class.__name__}AutoSerializer",
+ (GenericPluginSerializer,),
+ {
+ "Meta": meta_class,
+ },
+ )
+
+
class PluginDefinitionSerializer(serializers.Serializer):
"""
Serializer for plugin type definitions.
"""
- plugin_type = serializers.CharField(
- help_text="Unique identifier for the plugin type"
- )
+ plugin_type = serializers.CharField(help_text="Unique identifier for the plugin type")
title = serializers.CharField(help_text="Human readable name of the plugin")
type = serializers.CharField(help_text="Schema type")
properties = serializers.DictField(help_text="Property definitions")
@@ -186,17 +222,8 @@ def generate_plugin_definitions() -> dict[str, Any]:
definitions = {}
for plugin in plugin_pool.plugins.values():
- # Use plugin's serializer_class or create a simple fallback
- serializer_cls = getattr(plugin, "serializer_class", None)
-
- if not serializer_cls:
-
- class DynamicModelSerializer(serializers.ModelSerializer):
- class Meta:
- model = plugin.model
- fields = "__all__"
-
- serializer_cls = DynamicModelSerializer
+ serializer_cls = resolve_plugin_serializer(plugin, plugin.model)
+ serializer_cls = serializer_cls or get_auto_model_serializer(plugin.model)
try:
serializer_instance = serializer_cls()
@@ -207,11 +234,7 @@ class Meta:
if field_name in base_exclude:
continue
- properties[
- field_name
- ] = PluginDefinitionSerializer.map_field_to_schema(
- field, field_name
- )
+ properties[field_name] = PluginDefinitionSerializer.map_field_to_schema(field, field_name)
definitions[plugin.__name__] = {
"name": getattr(plugin, "name", plugin.__name__),
@@ -270,9 +293,7 @@ def map_field_to_schema(field: serializers.Field, field_name: str = "") -> dict:
# Extract nested properties
properties = {}
for nested_field_name, nested_field in field.fields.items():
- properties[
- nested_field_name
- ] = PluginDefinitionSerializer.map_field_to_schema(
+ properties[nested_field_name] = PluginDefinitionSerializer.map_field_to_schema(
nested_field, nested_field_name
)
if properties:
diff --git a/tests/endpoints/test_alias.py b/tests/endpoints/test_alias.py
new file mode 100644
index 0000000..4edbd93
--- /dev/null
+++ b/tests/endpoints/test_alias.py
@@ -0,0 +1,159 @@
+import unittest
+
+from cms.api import add_plugin, create_page
+from cms.models import PageContent
+from django.apps import apps
+from django.contrib.contenttypes.models import ContentType
+from rest_framework.reverse import reverse
+
+from tests.base import BaseCMSRestTestCase
+from tests.types import PLACEHOLDER_FIELD_TYPES
+from tests.utils import assert_field_types
+
+try:
+ from djangocms_alias.models import Alias, AliasContent, Category
+
+ HAS_ALIAS = True
+except ImportError:
+ HAS_ALIAS = False
+
+
+@unittest.skipUnless(HAS_ALIAS and apps.is_installed("djangocms_alias"), "djangocms_alias is not installed")
+class AliasAPITestCase(BaseCMSRestTestCase):
+ @classmethod
+ def setUpClass(cls):
+ """
+ Add alias with plugin content to a test page placeholder.
+ """
+ super().setUpClass()
+
+ cls.page = create_page(
+ title="Alias Test Page",
+ template="INHERIT",
+ language="en",
+ in_navigation=True,
+ )
+ cls.page_content = PageContent.objects.get(page=cls.page, language="en")
+ cls.page_content_type = ContentType.objects.get(model="pagecontent")
+ cls.page_placeholder = cls.page.get_placeholders(language="en").get(slot="content")
+
+ cls.category = Category.objects.create()
+ cls.category.set_current_language("en")
+ cls.category.name = "Alias Category"
+ cls.category.save()
+
+ cls.alias = Alias.objects.create(category=cls.category, static_code="test-alias")
+ cls.alias_content = AliasContent.objects.create(
+ alias=cls.alias,
+ name="Alias Content",
+ language="en",
+ )
+ cls.alias_placeholder = cls.alias_content.placeholder
+
+ add_plugin(
+ placeholder=cls.alias_placeholder,
+ plugin_type="TextPlugin",
+ language="en",
+ body="
Alias text
",
+ )
+
+ add_plugin(
+ placeholder=cls.page_placeholder,
+ plugin_type="Alias",
+ language="en",
+ alias=cls.alias,
+ )
+
+ def test_get(self):
+ """
+ Tests alias plugin rendering via the placeholder detail endpoint.
+
+ Verifies:
+ - Endpoint returns 200 OK for valid requests
+ - Response structure matches placeholder field types
+ - Alias plugin resolves its content with nested plugins
+ """
+
+ type_checks = PLACEHOLDER_FIELD_TYPES
+
+ plugin_type_checks = {
+ "plugin_type": str,
+ "content": list,
+ }
+
+ # GET request
+ response = self.client.get(
+ reverse(
+ "placeholder-detail",
+ kwargs={
+ "language": "en",
+ "content_type_id": self.page_content_type.id,
+ "object_id": self.page_content.id,
+ "slot": "content",
+ },
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ placeholder = response.json()
+
+ # Placeholder Validation
+ for field, expected_type in type_checks.items():
+ assert_field_types(
+ self,
+ placeholder,
+ field,
+ expected_type,
+ )
+
+ # Plugin Type Validation
+ for plugin in placeholder["content"]:
+ for field, expected_type in plugin_type_checks.items():
+ assert_field_types(
+ self,
+ plugin,
+ field,
+ expected_type,
+ )
+
+ # Alias content validation
+ alias_plugin = placeholder["content"][0]
+ self.assertTrue(alias_plugin["content"])
+ self.assertEqual(alias_plugin["content"][0]["plugin_type"], "TextPlugin")
+ self.assertIn("Alias text", alias_plugin["content"][0]["body"])
+
+ def test_circular_alias(self):
+ """
+ Tests that a circular alias reference returns empty content
+ instead of recursing infinitely.
+ """
+ # Add the alias as a plugin inside its own placeholder
+ add_plugin(
+ placeholder=self.alias_placeholder,
+ plugin_type="Alias",
+ language="en",
+ alias=self.alias,
+ )
+
+ response = self.client.get(
+ reverse(
+ "placeholder-detail",
+ kwargs={
+ "language": "en",
+ "content_type_id": self.page_content_type.id,
+ "object_id": self.page_content.id,
+ "slot": "content",
+ },
+ )
+ )
+ self.assertEqual(response.status_code, 200)
+ placeholder = response.json()
+
+ # The top-level alias plugin should resolve its content
+ alias_plugin = placeholder["content"][0]
+ self.assertTrue(alias_plugin["content"])
+
+ # The nested self-referencing alias plugin should have empty content
+ nested_alias = next(
+ plugin for plugin in alias_plugin["content"] if plugin.get("plugin_type") in ("Alias", "AliasPlugin")
+ )
+ self.assertEqual(nested_alias["content"], [])
diff --git a/tests/requirements/base.txt b/tests/requirements/base.txt
index 3fc5843..5e5d17b 100644
--- a/tests/requirements/base.txt
+++ b/tests/requirements/base.txt
@@ -2,6 +2,7 @@
djangorestframework
djangocms-text
django-filer
+djangocms-alias
beautifulsoup4
setuptools
drf-spectacular
diff --git a/tests/settings.py b/tests/settings.py
index 2b17ce1..a5c7b3e 100644
--- a/tests/settings.py
+++ b/tests/settings.py
@@ -30,6 +30,7 @@ def __getitem__(self, item):
"treebeard",
"sekizai",
"djangocms_text",
+ "djangocms_alias",
"djangocms_rest",
"tests.test_app",
"filer",