import contextlib import logging import sys from collections import OrderedDict from collections.abc import Generator from functools import partial from typing import Any from classytags.utils import flatten_context from django.conf import settings from django.contrib.sites.models import Site from django.db import models from django.http import HttpRequest from django.template import Context from django.utils.functional import cached_property from django.utils.html import format_html from django.utils.module_loading import import_string from django.utils.safestring import SafeText, mark_safe from django.utils.translation import get_language, override from django.views.debug import ExceptionReporter from cms.cache.placeholder import get_placeholder_cache, set_placeholder_cache from cms.exceptions import PlaceholderNotFound from cms.models import CMSPlugin, Page, PageContent, Placeholder from cms.plugin_pool import PluginPool from cms.toolbar.utils import ( get_placeholder_toolbar_js, get_plugin_toolbar_js, get_toolbar_from_request, ) from cms.utils import get_language_from_request from cms.utils.compat.warnings import RemovedInDjangoCMS60Warning from cms.utils.conf import get_cms_setting from cms.utils.permissions import has_plugin_permission from cms.utils.placeholder import ( get_toolbar_plugin_struct, rescan_placeholders_for_obj, restore_sekizai_context, ) from cms.utils.plugins import get_plugin_restrictions logger = logging.getLogger(__name__) def _unpack_plugins(parent_plugin: CMSPlugin) -> Generator[CMSPlugin, None, None]: yield parent_plugin for plugin in parent_plugin.child_plugin_instances or []: yield from _unpack_plugins(plugin) class RenderedPlaceholder: __slots__ = ( "language", "site_id", "cached", "editable", "placeholder", "has_content", ) def __init__( self, placeholder: Placeholder, language: str, site_id: int, cached: bool = False, editable: bool = False, has_content: bool = False, ): self.language = language self.site_id = site_id self.cached = cached self.editable = editable self.placeholder = placeholder self.has_content = has_content def __eq__(self, other): # The same placeholder rendered with different # parameters is considered the same. # This behavior is compatible with previous djangoCMS releases. return self.placeholder == other.placeholder def __ne__(self, other): return not self.__eq__(other) def __hash__(self): return hash(self.placeholder) class BaseRenderer: load_structure: bool = False placeholder_edit_template: str = "" def __init__(self, request: HttpRequest): self.request = request self._cached_plugin_classes = {} self._placeholders_content_cache = {} self._placeholders_by_page_cache = {} self._rendered_placeholders = OrderedDict() self._rendered_plugins_by_placeholder = {} self._plugins_with_perms = None @cached_property def current_page(self) -> Page: return self.request.current_page @cached_property def current_site(self) -> Site: return Site.objects.get_current(self.request) @cached_property def toolbar(self): return get_toolbar_from_request(self.request) @cached_property def templates(self): return self.toolbar.templates @cached_property def plugin_pool(self) -> PluginPool: import cms.plugin_pool return cms.plugin_pool.plugin_pool @cached_property def request_language(self) -> str: return get_language_from_request(self.request) def get_plugins_with_perms(self) -> list[CMSPlugin]: if self._plugins_with_perms is None: registered_plugins = self.plugin_pool.registered_plugins can_add_plugin = partial(has_plugin_permission, user=self.request.user, permission_type="add") self._plugins_with_perms = [ plugin for plugin in registered_plugins if can_add_plugin(plugin_type=plugin.value) ] return self._plugins_with_perms def get_placeholder_plugin_menu(self, placeholder: Placeholder, page: Page | None = None): plugin_menu = get_toolbar_plugin_struct( plugins=self.get_plugins_with_perms(), slot=placeholder.slot, obj=page, ) plugin_menu_template = self.templates.placeholder_plugin_menu_template return plugin_menu_template.render({"plugin_menu": plugin_menu}) def get_placeholder_toolbar_js(self, placeholder, page: models.Model | None = None): if page: import warnings warnings.warn( "The 'page' argument of 'get_placeholder_toolbar_js' is deprecated and will be removed in a future " "release. It can be safely removed from the call.", RemovedInDjangoCMS60Warning, stacklevel=2, ) plugins = self.plugin_pool.get_root_plugins(placeholder) # original plugin_types = [cls.__name__ for cls in plugins] allowed_plugins = plugin_types + self.plugin_pool.get_system_plugins() placeholder_toolbar_js = get_placeholder_toolbar_js( placeholder=placeholder, allowed_plugins=allowed_plugins, ) return placeholder_toolbar_js def get_plugin_toolbar_js(self, plugin: CMSPlugin, obj: models.Model): placeholder_cache = self._rendered_plugins_by_placeholder.setdefault(plugin.placeholder_id, {}) child_classes, _ = get_plugin_restrictions( plugin=plugin, page=obj, restrictions_cache=placeholder_cache, # Store non-global plugin-restriction in placeholder_cache ) # Make the resolved restrictions available to the drag-item template (which renders the # same instances) so it can decide whether children may be added without recomputing. plugin.child_class_restrictions = child_classes content = get_plugin_toolbar_js( plugin, children=child_classes, ) return content def get_plugin_class(self, plugin: CMSPlugin) -> type: plugin_type = plugin.plugin_type if plugin_type not in self._cached_plugin_classes: self._cached_plugin_classes[plugin_type] = self.plugin_pool.get_plugin(plugin_type) return self._cached_plugin_classes[plugin_type] def get_plugins_to_render(self, placeholder: Placeholder, language: str, template: str | None): from cms.utils.plugins import get_plugins plugins = get_plugins( request=self.request, placeholder=placeholder, template=template, lang=language, ) return plugins def get_rendered_plugins_cache(self, placeholder: Placeholder): blank: dict[str, Any] = { "plugins": [], "plugin_parents": {}, "plugin_children": {}, } return self._rendered_plugins_by_placeholder.get(placeholder.pk, blank) def get_rendered_placeholders(self) -> list[Placeholder]: rendered = list(self._rendered_placeholders.values()) return [r.placeholder for r in rendered] def get_rendered_editable_placeholders(self) -> list[Placeholder]: rendered = list(self._rendered_placeholders.values()) return [r.placeholder for r in rendered if r.editable] class ContentRenderer(BaseRenderer): plugin_edit_template = ( '{content}' '' ) placeholder_edit_template = ( '{content}
{plugin_js}{placeholder_js}' ) def __init__(self, request: HttpRequest): super().__init__(request) self._placeholders_are_editable = bool(self.toolbar.edit_mode_active) def placeholder_cache_is_enabled(self): if not get_cms_setting("PLACEHOLDER_CACHE"): return False if self.request.user.is_staff: return False return not self._placeholders_are_editable def render_placeholder( self, placeholder: Placeholder, context: Context, language: str | None = None, page: Page | None = None, editable: bool = False, use_cache: bool = False, nodelist: Any | None = None, width: int | None = None, ): from sekizai.helpers import Watcher language = language or self.request_language editable = editable and self._placeholders_are_editable if use_cache and not editable and placeholder.cache_placeholder: use_cache = self.placeholder_cache_is_enabled() else: use_cache = False if use_cache: cached_value = self._get_cached_placeholder_content( placeholder=placeholder, language=language, ) else: cached_value = None if cached_value is not None: # User has opted to use the cache # and there is something in the cache restore_sekizai_context(context, cached_value["sekizai"]) return mark_safe(cached_value["content"]) context.push() width = width or placeholder.default_width template = page.get_template() if page and hasattr(page, "get_template") else None if width: context["width"] = width # Add extra context as defined in settings, but do not overwrite existing context variables, # since settings are general and database/template are specific # TODO this should actually happen as a plugin context processor, but these currently overwrite # existing context -- maybe change this order? for key, value in placeholder.get_extra_context(template).items(): if key not in context: context[key] = value if use_cache: watcher = Watcher(context) plugin_content = self.render_plugins( placeholder, language=language, context=context, editable=editable, template=template, ) try: placeholder_content = "".join(plugin_content) except Exception as e: context["exc_info"] = sys.exc_info() placeholder_content = self.render_exception("rendering placeholder", context, placeholder, editable) if not get_cms_setting("CATCH_PLUGIN_500_EXCEPTION"): if not self.toolbar.edit_mode_active and not self.toolbar.preview_mode_active: raise e from None if not placeholder_content and nodelist: # should be nodelist from a template placeholder_content = nodelist.render(context) if use_cache: content = { "content": placeholder_content, "sekizai": watcher.get_changes(), } set_placeholder_cache( placeholder, lang=language, site_id=self.current_site.pk, content=content, request=self.request, ) rendered_placeholder = RenderedPlaceholder( placeholder=placeholder, language=language, site_id=self.current_site.pk, cached=use_cache, editable=editable, has_content=bool(placeholder_content), ) if placeholder.pk not in self._rendered_placeholders: # First time this placeholder is rendered if not self.toolbar._cache_disabled: # The toolbar middleware needs to know if the response # is to be cached. # Set the _cache_disabled flag to the value of cache_placeholder # only if the flag is False (meaning cache is enabled). self.toolbar._cache_disabled = not use_cache self._rendered_placeholders[placeholder.pk] = rendered_placeholder if editable: request = context.get("request", None) with override(request.toolbar.toolbar_language) if request else contextlib.nullcontext(): data = self.get_editable_placeholder_context(placeholder, page=page) data["content"] = placeholder_content placeholder_content = self.placeholder_edit_template.format(**data) context.pop() return mark_safe(placeholder_content) def get_editable_placeholder_context(self, placeholder: Placeholder, page: Page | None = None) -> dict: obj = page or placeholder.source placeholder_cache = self.get_rendered_plugins_cache(placeholder) placeholder_toolbar_js = self.get_placeholder_toolbar_js(placeholder) plugin_toolbar_js_bits = ( self.get_plugin_toolbar_js(plugin, obj=obj) for plugin in placeholder_cache["plugins"] ) context = { "plugin_js": "".join(plugin_toolbar_js_bits), "placeholder_js": placeholder_toolbar_js, "placeholder_id": placeholder.pk, } return context def render_obj_placeholder( self, slot: str, context: Context, inherit: bool, nodelist=None, editable: bool = True, ): # Get current object from toolbar current_obj = self.toolbar.get_object() # Check if page, if so delegate to render_page_placeholder if self.current_page and (isinstance(current_obj, (PageContent, type(None)))): return self.render_page_placeholder( slot, context, inherit, nodelist=nodelist, editable=editable, ) # Not page, therefore we will use toolbar object as # the current object and render the placeholder if current_obj is None: raise PlaceholderNotFound(f"No object found for placeholder '{slot}'") placeholder = rescan_placeholders_for_obj(current_obj).get(slot) content = self.render_placeholder( placeholder, context=context, page=current_obj, editable=editable, use_cache=True, nodelist=None, ) return content def render_page_placeholder( self, slot: str, context: Context, inherit: bool, page: Page | None = None, nodelist=None, editable: bool = True, ): if not self.current_page: # This method should only be used when rendering a cms page. return "" current_page = page or self.current_page language = get_language() placeholder_cache = self._placeholders_by_page_cache if current_page.pk not in placeholder_cache: # Instead of loading plugins for this one placeholder # try and load them for all placeholders on the page. self._preload_placeholders_for_page(current_page, language=language) try: placeholder = placeholder_cache[current_page.pk][slot] except KeyError: content = "" placeholder = None else: content = self.render_placeholder( placeholder, context=context, language=language, page=placeholder.source, editable=editable, use_cache=True, nodelist=None, ) should_inherit = ( inherit and not content and current_page.parent # The placeholder cache is primed when the first placeholder # is loaded. If the current page's parent is not in there, # it means its cache was never primed as it wasn't necessary. and current_page.parent.pk in placeholder_cache # don't display inherited plugins in edit mode, so that the user doesn't # mistakenly edit/delete them. This is a fix for issue #1303. See the discussion # there for possible enhancements and not self.toolbar.edit_mode_active ) if should_inherit: # nodelist is set to None to avoid rendering the nodes inside # a {% placeholder or %} block tag. content = self.render_page_placeholder( slot, context, inherit=True, page=current_page.parent, nodelist=None, editable=False, ) if placeholder and (editable and self._placeholders_are_editable): # In edit mode, the contents of the placeholder are mixed with our # internal toolbar markup, so the content variable will always be True. # Use the rendered placeholder has_content flag instead. has_content = self._rendered_placeholders[placeholder.pk].has_content else: # User is not in edit mode or the placeholder doesn't exist. # Either way, we can trust the content variable. has_content = bool(content) if not has_content and nodelist: return content + nodelist.render(context) return content def render_plugin( self, instance: CMSPlugin, context: Context, placeholder: Placeholder | None = None, editable: bool = False, ): context["_last_plugin"] = instance # Used if an exception is rendered if placeholder is None: placeholder = instance.placeholder instance, plugin = instance.get_plugin_instance() if not instance or not plugin.render_plugin: return "" # we'd better pass a flat dict to template.render # as plugin.render can return pretty much any kind of context / dictionary # we'd better flatten it and force to a Context object # flattening the context means that template must be an engine-specific template object # which is guaranteed by get_cached_template if the template returned by # plugin._get_render_template is either a string or an engine-specific template object context = PluginContext(context, instance, placeholder) context = plugin.render(context, instance, placeholder.slot) context = flatten_context(context) template_name = plugin._get_render_template(context, instance, placeholder) template = self.templates.get_cached_template(template_name) content = template.render(context) for path in get_cms_setting("PLUGIN_PROCESSORS"): processor = import_string(path) content = processor(instance, placeholder, content, context) if editable: is_slot = getattr(plugin, "is_slot", False) content = self.plugin_edit_template.format( pk=instance.pk, placeholder=instance.placeholder_id, content=content, position=instance.position, disabled=' cms-slot' if is_slot else '', ) placeholder_cache = self._rendered_plugins_by_placeholder.setdefault(placeholder.pk, {}) placeholder_cache.setdefault("plugins", []).append(instance) return mark_safe(content) def render_exception(self, action: str, context: Context, placeholder: Placeholder, editable: bool) -> str: exc, value, traceback = context["exc_info"] placeholder_source_obj = placeholder.source.__class__._meta.verbose_name message = ( f'{exc.__name__}: {value} when {action} "{placeholder}" on ' f'{placeholder_source_obj} object "{placeholder.source}"' ) if "_last_plugin" in context: instance = context["_last_plugin"] try: description = f"{instance._meta.verbose_name} {instance.get_short_description()}" except Exception: description = f"{instance._meta.verbose_name}" message += f', plugin #{instance.pk} "{description}"' logger.error(message, exc_info=(exc, value, traceback)) if editable: if settings.DEBUG: reporter = ExceptionReporter(context["request"], exc, value, traceback) html = reporter.get_traceback_html() else: html = "" heading = format_html('