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}
{value}
{close}' @@ -207,17 +174,13 @@ class RESTRenderer(ContentRenderer): placeholder_edit_template = "{content}{plugin_js}{placeholder_js}" - def render_plugin( - self, instance, context, placeholder=None, editable: bool = False - ): + def render_plugin(self, instance, context, placeholder=None, editable: bool = False): """ Render a CMS plugin instance using the serialize_cms_plugin function. """ data = serialize_cms_plugin(instance, context) or {} children = [ - self.render_plugin( - child, context, placeholder=placeholder, editable=editable - ) + self.render_plugin(child, context, placeholder=placeholder, editable=editable) for child in getattr(instance, "child_plugin_instances", []) ] or None content = OBJ_TEMPLATE.format(**highlight_json(data, children=children)) @@ -229,15 +192,11 @@ def render_plugin( content=content, position=instance.position, ) - placeholder_cache = self._rendered_plugins_by_placeholder.setdefault( - placeholder.pk, {} - ) + placeholder_cache = self._rendered_plugins_by_placeholder.setdefault(placeholder.pk, {}) placeholder_cache.setdefault("plugins", []).append(instance) return mark_safe(content) - def render_plugins( - self, placeholder, language, context, editable=False, template=None - ): + def render_plugins(self, placeholder, language, context, editable=False, template=None): yield "
".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",