clone cms with menus, make menus standalone app

This commit is contained in:
bruks 2026-09-30 15:27:04 +01:00
commit afb096b7a3
1153 changed files with 318897 additions and 37 deletions

798
cms/plugin_rendering.py Normal file
View file

@ -0,0 +1,798 @@
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 = (
'<template class="cms-plugin '
'cms-plugin-start cms-plugin-{pk}{disabled}" data-cms-placeholder="{placeholder}"></template>{content}'
'<template class="cms-plugin cms-plugin-end cms-plugin-{pk}"></template>'
)
placeholder_edit_template = (
'{content} <div class="cms-placeholder cms-placeholder-{placeholder_id}"></div>{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('<h2 class="cms-rendering-exception-title">{}</h2>', message)
if "_last_plugin" in context:
# Make error message editable by double-click to open the editor for the plugin causing the exception
is_slot = getattr(instance, "is_slot", False)
heading = self.plugin_edit_template.format(
pk=instance.pk,
placeholder=instance.placeholder_id,
content=heading,
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 f'<div class="cms-rendering-exception">{heading}{html}</div>'
return ""
def render_plugins(
self,
placeholder: Placeholder,
language: str,
context,
editable: bool = False,
template: str | None = None,
) -> Generator[SafeText | str, None, None]:
plugins = self.get_plugins_to_render(
placeholder=placeholder,
template=template,
language=language,
)
for plugin in plugins:
plugin._placeholder_cache = placeholder
yield self.render_plugin(plugin, context, placeholder, editable)
def _get_cached_placeholder_content(self, placeholder, language):
"""
Returns a dictionary mapping placeholder content and sekizai data.
Returns None if no cache is present.
"""
# Placeholders can be rendered multiple times under different sites
# it's important to have a per-site "cache".
site_id = self.current_site.pk
site_cache = self._placeholders_content_cache.setdefault(site_id, {})
# Placeholders can be rendered multiple times under different languages
# it's important to have a per-language "cache".
language_cache = site_cache.setdefault(language, {})
if placeholder.pk not in language_cache:
cached_value = get_placeholder_cache(
placeholder,
lang=language,
site_id=site_id,
request=self.request,
)
if cached_value is not None:
# None means nothing in the cache
# Anything else is a valid value
language_cache[placeholder.pk] = cached_value
return language_cache.get(placeholder.pk)
def _get_content_object(self, page, slots=None):
toolbar_obj = self.toolbar.get_object()
if isinstance(toolbar_obj, PageContent) and toolbar_obj.page == page:
# Current object belongs to the page itself
return Placeholder.objects.get_for_obj(toolbar_obj)
elif slots:
# If looking for inherited placeholders, slots is specified
if self.toolbar.preview_mode_active or self.toolbar.edit_mode_active:
page_content = (
page.pagecontent_set(manager="admin_manager")
.current_content(language=self.request_language)
.first()
)
else:
page_content = page.pagecontent_set.filter(language=self.request_language).first()
return Placeholder.objects.get_for_obj(page_content) if page_content else Placeholder.objects.none()
elif page_content := page.get_content_obj(self.request_language, fallback=False):
PageContent.page.field.set_cached_value(page_content, page)
# Creates any placeholders missing on the page
return page_content.rescan_placeholders().values()
else:
return Placeholder.objects.none()
def _preload_placeholders_for_page(self, page, language=None, slots=None, inherit=False):
"""
Populates the internal plugin cache of each placeholder
in the given page if the placeholder has not been
previously cached.
"""
from cms.utils.plugins import assign_plugins
placeholders = self._get_content_object(page, slots=slots)
language = language or self.request_language
if inherit:
# When the inherit flag is True,
# assume all placeholders found are inherited and thus prefetch them.
slots_w_inheritance = [pl.slot for pl in placeholders]
elif not self.toolbar.edit_mode_active:
# Scan through the page template to find all placeholders
# that have inheritance turned on.
slots_w_inheritance = [pl.slot for pl in page.get_declared_placeholders() if pl.inherit]
else:
# Inheritance is turned off on edit-mode
slots_w_inheritance = []
if self.placeholder_cache_is_enabled():
_cached_content = self._get_cached_placeholder_content
# Only prefetch plugins if the placeholder
# has not been cached.
placeholders_to_fetch = [
placeholder
for placeholder in placeholders
if _cached_content(placeholder, language) is None
]
else:
# cache is disabled, prefetch plugins for all
# placeholders in the page.
placeholders_to_fetch = placeholders
if placeholders_to_fetch:
assign_plugins(
request=self.request,
placeholders=placeholders_to_fetch,
template=page.get_template(),
lang=language,
)
# Inherit only placeholders that have no plugins
# or are not cached.
placeholders_to_inherit = [
pl.slot
for pl in placeholders
if not getattr(pl, "_plugins_cache", None) and pl.slot in slots_w_inheritance
]
if page.parent and placeholders_to_inherit:
self._preload_placeholders_for_page(
page=page.parent,
language=language,
slots=placeholders_to_inherit,
inherit=True,
)
# Internal cache mapping placeholder slots
# to placeholder instances.
page_placeholder_cache = {}
for placeholder in placeholders:
# Save a query when the placeholder toolbar is rendered.
placeholder.page = page
page_placeholder_cache[placeholder.slot] = placeholder
self._placeholders_by_page_cache[page.pk] = page_placeholder_cache
class StructureRenderer(BaseRenderer):
load_structure = True
placeholder_edit_template = """
<template data-cms id="cms-plugin-child-classes-{placeholder_id}" type="text/cms-template">
{plugin_menu_js}
</template>
{plugin_js}{placeholder_js}
"""
def get_plugins_to_render(self, *args, **kwargs):
plugins = super().get_plugins_to_render(*args, **kwargs)
for plugin in plugins:
yield from _unpack_plugins(plugin)
def render_placeholder(self, placeholder, language, obj=None):
rendered_plugins = self.render_plugins(placeholder, language=language, obj=obj)
plugin_js_output = "".join(rendered_plugins)
placeholder_toolbar_js = self.get_placeholder_toolbar_js(placeholder)
rendered_placeholder = RenderedPlaceholder(
placeholder=placeholder,
language=language,
site_id=self.current_site.pk,
cached=False,
editable=True,
)
if placeholder.pk not in self._rendered_placeholders:
self._rendered_placeholders[placeholder.pk] = rendered_placeholder
placeholder_structure_js = self.placeholder_edit_template.format(
placeholder_id=placeholder.pk,
plugin_js=plugin_js_output,
plugin_menu_js=self.get_placeholder_plugin_menu(placeholder, page=obj),
placeholder_js=placeholder_toolbar_js,
)
return mark_safe(placeholder_structure_js)
def render_page_placeholder(self, page, placeholder, language=None):
return self.render_placeholder(placeholder, language=language, obj=page)
def render_plugin(self, instance, obj=None):
placeholder_cache = self._rendered_plugins_by_placeholder.setdefault(instance.placeholder_id, {})
placeholder_cache.setdefault("plugins", []).append(instance)
return self.get_plugin_toolbar_js(instance, obj=obj)
def render_plugins(self, placeholder, language, obj=None):
template = obj.get_template() if obj and hasattr(obj, "get_template") else None
plugins = self.get_plugins_to_render(placeholder, language, template)
for plugin in plugins:
plugin._placeholder_cache = placeholder
yield self.render_plugin(plugin, obj=obj)
class LegacyRenderer(ContentRenderer):
load_structure = True
placeholder_edit_template = """
{content}
<div class="cms-placeholder cms-placeholder-{placeholder_id}"></div>
<template data-cms id="cms-plugin-child-classes-{placeholder_id}" type="text/cms-template">
{plugin_menu_js}
</template>
{plugin_js}{placeholder_js}
"""
def get_editable_placeholder_context(self, placeholder, page=None):
context = super().get_editable_placeholder_context(placeholder, page)
context["plugin_menu_js"] = self.get_placeholder_plugin_menu(placeholder, page=page)
return context
class PluginContext(Context):
"""
This subclass of template.Context automatically populates itself using
the processors defined in CMS_PLUGIN_CONTEXT_PROCESSORS.
Additional processors can be specified as a list of callables
using the "processors" keyword argument.
"""
def __init__(self, dict_, instance, placeholder, processors=None, current_app=None):
dict_ = flatten_context(dict_)
super().__init__(dict_)
if not processors:
processors = []
for path in get_cms_setting("PLUGIN_CONTEXT_PROCESSORS"):
processor = import_string(path)
self.update(processor(instance, placeholder, self))
for processor in processors:
self.update(processor(instance, placeholder, self))