dynamicweb/cms/utils/placeholder.py

442 lines
17 KiB
Python

import operator
import os
import warnings
from collections import OrderedDict, defaultdict
from functools import cache
from typing import TYPE_CHECKING
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.db import connection, models
from django.template import (
Context,
NodeList,
Template,
TemplateDoesNotExist,
TemplateSyntaxError,
Variable,
engines,
)
from django.template.base import VariableNode
from django.template.loader import get_template
from django.template.loader_tags import BlockNode, ExtendsNode, IncludeNode
from sekizai.helpers import get_varname
from cms.exceptions import DuplicatePlaceholderWarning
from cms.models import EmptyPageContent, Placeholder
from cms.utils.compat.warnings import RemovedInDjangoCMS60Warning
from cms.utils.conf import get_cms_setting
if TYPE_CHECKING:
from cms.templatetags.cms_tags import DeclaredPlaceholder
RANGE_START = 128
def _get_nodelist(tpl):
if hasattr(tpl, "template"):
return tpl.template.nodelist
else:
return tpl.nodelist
def get_context():
if engines is not None:
context = Context()
context.template = Template("")
return context
else:
return {}
def _get_placeholder_settings():
"""Convert CMS_PLACEHOLDER_CONF into a faster to access format since it is accessed many times
in each request response cycle:
* setting key on the first level dict, scope on the second level
* resolve inheritance
* Check of template-specific configs exist (expensive to access)
"""
conf = get_cms_setting("PLACEHOLDER_CONF")
template_in_conf = any(".htm" in key for key in conf if key)
def resolve_inheritance(key, visited=None):
if visited is None:
visited = set()
if key in visited:
raise ImproperlyConfigured(f"Circular inheritance detected in CMS_PLACEHOLDER_CONF at key '{key}'")
visited.add(key)
if "inherit" in conf[key]:
return resolve_inheritance(conf[key]["inherit"], visited) | {
k: v for k, v in conf[key].items() if k != "inherit"
}
# Return a copy: the caller updates the result in place, which must
# not leak into the inherited-from slot's own configuration.
return dict(conf[key])
new_conf = {}
for key, value in conf.items():
if "inherit" in value:
new_conf[key] = resolve_inheritance(value["inherit"])
new_conf[key].update({k: v for k, v in value.items() if k != "inherit"})
else:
new_conf[key] = value
settings = defaultdict(dict)
for key, value in new_conf.items():
for setting, setting_value in value.items():
settings[setting][key] = setting_value
return settings, template_in_conf
_placeholder_settings, _template_in_conf = _get_placeholder_settings()
def _clear_placeholder_conf_cache():
# Needed by the override_placeholder_conf context manager for tests
global _placeholder_settings, _template_in_conf
_placeholder_settings, _template_in_conf = _get_placeholder_settings()
def get_placeholder_conf(setting: str, placeholder: str, template: str | None = None, default=None):
"""
Returns the placeholder configuration for a given setting. The key would for
example be 'plugins' or 'name'.
Resulting value will be the last from:
CMS_PLACEHOLDER_CONF[None] (global)
CMS_PLACEHOLDER_CONF['template'] (if template is given)
CMS_PLACEHOLDER_CONF['placeholder'] (where placeholder denotes a slot)
CMS_PLACEHOLDER_CONF['template placeholder'] (if template is given)
Template is only evaluated if the placeholder configuration contains key with ".html" or ".htm"
"""
if setting not in _placeholder_settings:
return default
if _template_in_conf and template is not None:
keys = [f"{template} {placeholder}", placeholder, str(template), None]
else:
keys = [placeholder, None]
conf = _placeholder_settings[setting]
for key in keys:
if (value := conf.get(key, None)) is not None:
return value
return default
def get_toolbar_plugin_struct(plugins, slot=None, obj=None, page=None):
"""
Return the list of plugins to render in the toolbar.
The dictionary contains the label, the classname and the module for the
plugin.
Names and modules can be defined on a per-placeholder basis using
'plugin_modules' and 'plugin_labels' attributes in CMS_PLACEHOLDER_CONF
:param plugins: list of plugins
:param slot: placeholder slot name
:param page: the page
:return: list of dictionaries
"""
template = None
obj = obj or page
if page:
import warnings
warnings.warn(
"The 'page' argument is deprecated. Please use 'obj' instead.",
RemovedInDjangoCMS60Warning,
stacklevel=2,
)
if obj and hasattr(obj, "get_template"):
template = obj.get_template()
modules = get_placeholder_conf("plugin_modules", slot, template, default={})
names = get_placeholder_conf("plugin_labels", slot, template, default={})
main_list = []
# plugin.value points to the class name of the plugin
# It's added on registration. TIL.
for plugin in plugins:
main_list.append(
{
"value": plugin.value,
"name": names.get(plugin.value, plugin.name),
"module": modules.get(plugin.value, plugin.module),
"add_form": plugin.show_add_form and not plugin.is_slot,
}
)
return sorted(main_list, key=operator.itemgetter("module"))
def validate_placeholder_name(name):
if not isinstance(name, str):
raise ImproperlyConfigured("Placeholder identifier names need to be of type string. ")
try:
name.encode("ascii")
except UnicodeEncodeError:
raise ImproperlyConfigured(
"Placeholder identifiers names may not "
"contain non-ascii characters. If you wish your placeholder "
"identifiers to contain non-ascii characters when displayed to "
"users, please use the CMS_PLACEHOLDER_CONF setting with the 'name' "
"key to specify a verbose name."
)
def restore_sekizai_context(context, changes):
varname = get_varname()
sekizai_container = context.get(varname)
for key, values in changes.items():
sekizai_namespace = sekizai_container[key]
for value in values:
sekizai_namespace.append(value)
def _scan_placeholders(nodelist, node_class=None, current_block=None, ignore_blocks=None):
from cms.templatetags.cms_tags import Placeholder
if not node_class:
node_class = Placeholder
nodes = []
if ignore_blocks is None:
# List of BlockNode instances to ignore.
# This is important to avoid processing overridden block nodes.
ignore_blocks = []
for node in nodelist:
# check if this is a placeholder first
if isinstance(node, node_class):
nodes.append(node)
elif isinstance(node, IncludeNode):
# if there's an error in the to-be-included template, node.template becomes None
if node.template:
# Check if it quacks like a template object, if not
# presume is a template path and get the object out of it
if not callable(getattr(node.template, "render", None)):
# If it's a variable there is no way to expand it at this stage so we
# need to skip it
if isinstance(node.template.var, Variable):
continue
else:
try:
template = get_template(node.template.var)
except TemplateDoesNotExist:
# Include might be inside if else, so no need to error out here
# Actual errors will be raised at render time
continue
else:
template = node.template
nodes += _scan_placeholders(_get_nodelist(template), node_class, current_block)
# handle {% extends ... %} tags
elif isinstance(node, ExtendsNode):
try:
nodes += _get_placeholder_nodes_from_extend(node, node_class)
except (TemplateSyntaxError, TemplateDoesNotExist):
# If the extends tag uses a variable that's not available in the
# scanning context (like CMS_TEMPLATE), we can't resolve the parent
# template. Skip scanning this extends node.
continue
# in block nodes we have to scan for super blocks
elif isinstance(node, VariableNode) and current_block:
if node.filter_expression.token == "block.super":
if not hasattr(current_block.super, "nodelist"):
raise TemplateSyntaxError("Cannot render block.super for blocks without a parent.")
nodes += _scan_placeholders(_get_nodelist(current_block.super), node_class, current_block.super)
# ignore nested blocks which are already handled
elif isinstance(node, BlockNode) and node.name in ignore_blocks:
continue
# if the node has the newly introduced 'child_nodelists' attribute, scan
# those attributes for nodelists and recurse them
elif hasattr(node, "child_nodelists"):
for nodelist_name in node.child_nodelists:
if hasattr(node, nodelist_name):
subnodelist = getattr(node, nodelist_name)
if isinstance(subnodelist, NodeList):
if isinstance(node, BlockNode):
current_block = node
nodes += _scan_placeholders(subnodelist, node_class, current_block, ignore_blocks)
# else just scan the node for nodelist instance attributes
else:
for attr in dir(node):
obj = getattr(node, attr)
if isinstance(obj, NodeList):
if isinstance(node, BlockNode):
current_block = node
nodes += _scan_placeholders(obj, node_class, current_block, ignore_blocks)
return nodes
def get_placeholders(template: str) -> list["DeclaredPlaceholder"]:
compiled_template = get_template(template)
placeholders = []
nodes = _scan_placeholders(_get_nodelist(compiled_template))
clean_placeholders = []
for node in nodes:
placeholder = node.get_declaration()
slot = placeholder.slot
if slot in clean_placeholders:
warnings.warn(
f'Duplicate {{% placeholder "{slot}" %}} ' f"in template {template}.",
DuplicatePlaceholderWarning,
)
else:
validate_placeholder_name(slot)
placeholders.append(placeholder)
clean_placeholders.append(slot)
return placeholders
if settings.DEBUG is False or os.environ.get("DJANGO_TESTS"):
# Cache in production only, so template changes in development
# are always reflected without needing a server restart
get_placeholders = cache(get_placeholders)
def _get_block_nodes(extend_node):
parent = extend_node.get_parent(get_context())
parent_nodelist = _get_nodelist(parent)
parent_nodes = parent_nodelist.get_nodes_by_type(BlockNode)
parent_extend_nodes = parent_nodelist.get_nodes_by_type(ExtendsNode)
if parent_extend_nodes:
# Start at the top
# Scan the extends node from the parent (if any)
nodes = _get_block_nodes(parent_extend_nodes[0])
else:
nodes = OrderedDict()
# Continue with the parent template nodes
for node in parent_nodes:
nodes[node.name] = node
# Move on to the current template nodes
current_nodes = _get_nodelist(extend_node).get_nodes_by_type(BlockNode)
for node in current_nodes:
if node.name in nodes:
# set this node as the super node (for {{ block.super }})
node.super = nodes[node.name]
nodes[node.name] = node
return nodes
def _get_placeholder_nodes_from_extend(extend_node, node_class):
"""
Returns a list of placeholders found in the parent template(s) of this
ExtendsNode
"""
# This is a dictionary mapping all BlockNode instances found
# in the template that contains the {% extends %} tag
block_nodes = _get_block_nodes(extend_node)
block_names = list(block_nodes.keys())
placeholders = []
for block in block_nodes.values():
placeholders.extend(_scan_placeholders(_get_nodelist(block), node_class, block, block_names))
# Scan topmost template for placeholder outside of blocks
parent_template = _find_topmost_template(extend_node)
placeholders += _scan_placeholders(_get_nodelist(parent_template), node_class, None, block_names)
return placeholders
def _find_topmost_template(extend_node):
parent_template = extend_node.get_parent(get_context())
for node in _get_nodelist(parent_template).get_nodes_by_type(ExtendsNode):
# Their can only be one extend block in a template, otherwise django raises an exception
return _find_topmost_template(node)
# No ExtendsNode
return extend_node.get_parent(get_context())
def rescan_placeholders_for_obj(obj: models.Model) -> dict[str, Placeholder]:
from cms.models import CMSPlugin, Placeholder
declared_placeholders = get_declared_placeholders_for_obj(obj)
placeholders = {pl.slot: None for pl in declared_placeholders} # Fix order of placeholders in dict
# Fill in existing placeholders
placeholders.update(
{
placeholder.slot: placeholder
for placeholder in Placeholder.objects.get_for_obj(obj)
if placeholder.slot in placeholders
}
)
# Create missing placeholders
new_placeholders = [
Placeholder(slot=slot, source=obj) for slot, placeholder in placeholders.items() if placeholder is None
]
if new_placeholders:
if connection.features.can_return_rows_from_bulk_insert:
Placeholder.objects.bulk_create(new_placeholders)
for placeholder in new_placeholders:
# No plugins in newly created placeholder yet
placeholder._prefetched_objects_cache = {"cmsplugin_set": CMSPlugin.objects.none()}
placeholders[placeholder.slot] = placeholder
else:
# Some MySql versions do not support returning IDs from bulk_create
for placeholder in new_placeholders:
placeholder.save()
# No plugins in newly created placeholder yet
placeholder._prefetched_objects_cache = {"cmsplugin_set": CMSPlugin.objects.none()}
placeholders[placeholder.slot] = placeholder
return placeholders
def get_declared_placeholders_for_obj(obj: models.Model | EmptyPageContent | None) -> list["DeclaredPlaceholder"]:
"""Returns declared placeholders for an object. The object is supposed to either have a method
``get_placeholder_slots`` which returns the list of placeholders or a method ``get_template``
which returns the template path as a string that renders the object. ``get_declared_placeholders`` returns
a list of placeholders used in the template by the ``{% placeholder %}`` template tag.
"""
template = getattr(obj, "get_template", lambda: None)()
if template:
return get_placeholders(template)
if hasattr(obj, "get_placeholder_slots"):
from cms.templatetags.cms_tags import DeclaredPlaceholder
return [
DeclaredPlaceholder(slot=slot, inherit=False) if isinstance(slot, str) else DeclaredPlaceholder(**slot)
for slot in obj.get_placeholder_slots()
]
raise NotImplementedError(
"%s should implement either get_placeholder_slots or get_template" % obj.__class__.__name__
)
def get_placeholder_from_slot(
placeholder_relation: models.Manager, slot: str, template_obj=None, default_width: int | None = None
) -> Placeholder:
"""Retrieves the placeholder instance for a PlaceholderRelationField either by scanning the template
of the template_obj (if given) or by creating or getting a Placeholder in the database
"""
if hasattr(template_obj, "get_template"):
# Tries to get a placeholder (based on the template for the template_obj
# or - if non exists - raises a Placeholder.DoesNotExist exception
# Placeholders are marked in the template with {% placeholder %}
try:
return placeholder_relation.get(slot=slot)
except Placeholder.DoesNotExist:
return rescan_placeholders_for_obj(template_obj).get(slot)
else:
# Gets or creates the placeholder in any model. Placeholder is
# rendered by {% render_placeholder %}
return placeholder_relation.get_or_create(slot=slot, default_width=default_width)[0]