dynamicweb/cms/toolbar/utils.py

370 lines
15 KiB
Python

from __future__ import annotations
import json
from collections import defaultdict, deque
from collections.abc import Sequence
from typing import Any
from django.apps import apps
from django.contrib.contenttypes.models import ContentType
from django.contrib.sites.models import Site
from django.db import models
from django.http import HttpRequest, QueryDict
from django.urls import NoReverseMatch
from django.utils.encoding import force_str
from django.utils.translation import (
get_language,
get_language_from_path,
gettext,
override as force_language,
)
from sekizai.context import SekizaiContext
from sekizai.helpers import get_varname
from cms.constants import PLACEHOLDER_TOOLBAR_JS, PLUGIN_TOOLBAR_JS
from cms.models import CMSPlugin, PageContent, Placeholder
from cms.utils.conf import get_cms_setting
from cms.utils.urlutils import admin_reverse
def get_placeholder_toolbar_js(placeholder: Placeholder, allowed_plugins: list[str] | None = None) -> str:
label = placeholder.get_label() or ''
help_text = gettext(
'Add plugin to placeholder "%(placeholder_label)s"'
) % {'placeholder_label': label}
data = {
'type': 'placeholder',
'name': force_str(label),
'placeholder_id': str(placeholder.pk),
'plugin_restriction': allowed_plugins or [],
'addPluginHelpTitle': force_str(help_text),
'urls': {
'add_plugin': admin_reverse('cms_placeholder_add_plugin'),
'copy_plugin': admin_reverse('cms_placeholder_copy_plugins'),
}
}
return PLACEHOLDER_TOOLBAR_JS % {'pk': placeholder.pk, 'config': json.dumps(data)}
def get_plugin_toolbar_info(plugin: CMSPlugin, children: list[str] | None = None, parents: list[str] | None = None) -> dict[str, Any]:
# ``parents`` is deprecated (see CMSPlugin.get_plugin_info); forwarded for backwards compatibility.
data = plugin.get_plugin_info(children=children, parents=parents)
help_text = gettext(
'Add plugin to %(plugin_name)s'
) % {'plugin_name': data['plugin_name']}
data.update({
"onClose": False,
"addPluginHelpTitle": force_str(help_text),
"plugin_order": '',
"plugin_restriction": children or [],
})
return data
def get_plugin_toolbar_js(plugin: CMSPlugin, children: list[str] | None = None, parents: list[str] | None = None) -> str:
# ``parents`` is deprecated (see CMSPlugin.get_plugin_info); forwarded for backwards compatibility.
data = get_plugin_toolbar_info(
plugin,
children=children,
parents=parents,
)
return PLUGIN_TOOLBAR_JS % {'pk': plugin.pk, 'config': json.dumps(data)}
def create_child_plugin_references(plugins: list[CMSPlugin]) -> deque[CMSPlugin]:
"""Creates the ``child_plugin_instances`` attribute on each plugin instance after downcasting."""
plugin_children = defaultdict(deque)
root_plugins = deque()
plugin_ids = frozenset(plugin.pk for plugin in plugins)
for plugin in reversed(plugins):
plugin.child_plugin_instances = plugin_children[plugin.pk]
if plugin.parent_id in plugin_ids:
plugin_children[plugin.parent_id].appendleft(plugin)
else:
root_plugins.appendleft(plugin)
return root_plugins
def get_plugin_tree(
request: HttpRequest,
plugins: Sequence[CMSPlugin],
restrictions: dict | None = None,
target_plugin: CMSPlugin | None = None,
) -> dict[str, Any]:
"""
Constructs a tree structure of CMS plugins for the toolbar.
Args:
request (HttpRequest): The HTTP request object.
plugins (list[CMSPlugin]): A list of CMSPlugin instances to be organized into a tree.
restrictions (Optional[dict], optional): A dictionary of plugin restrictions. Defaults to None.
target_plugin (Optional[CMSPlugin], optional): The target plugin to render.
Content will only be rendered if given. Defaults to None.
Returns:
tuple[dict[str, Any]]: A dictionary with up to five keys:
- 'html': A string of rendered HTML for the plugin tree.
- 'plugins': A flat list of plugin information dictionaries.
- 'content': The rendered content of target_plugin (if given) including its children
- 'target_position': The position of the target_plugin (if given)
- 'target_placeholder_id': The placeholder id of the target_plugin (if given)
"""
from cms.utils.plugins import downcast_plugins, get_plugin_restrictions
tree_data = []
tree_structure = []
restrictions = restrictions or {}
toolbar = get_toolbar_from_request(request)
template = toolbar.templates.drag_item_template
get_plugin_info = get_plugin_toolbar_info
placeholder = plugins[0].placeholder
copy_to_clipboard = toolbar.clipboard and placeholder.pk == toolbar.clipboard.pk
plugins = list(downcast_plugins(plugins, select_placeholder=True))
root_plugins = create_child_plugin_references(plugins)
def collect_plugin_data(plugin):
child_classes, _ = get_plugin_restrictions(
plugin=plugin,
restrictions_cache=restrictions,
page=placeholder.source,
)
# Make the resolved restrictions available to the drag-item template so it can decide
# whether children may be added without recomputing them per access.
plugin.child_class_restrictions = child_classes
plugin_info = get_plugin_info(
plugin,
children=child_classes,
)
tree_data.append(plugin_info)
for plugin_instance in plugin.child_plugin_instances:
collect_plugin_data(plugin_instance)
with force_language(toolbar.toolbar_language):
for root_plugin in root_plugins:
collect_plugin_data(root_plugin)
context = {
'plugin': root_plugin,
'request': request,
'clipboard': copy_to_clipboard,
'cms_toolbar': toolbar,
}
tree_structure.append(template.render(context))
tree_data.reverse()
content = {}
# Render the target plugin if given and all plugins are local
if target_plugin and all(plugin.plugin_class.is_local for plugin in plugins):
# Also provide the parent plugin to the context (if available)
downcasted = next(
(plugin for plugin in plugins if plugin.pk == target_plugin.pk), None
)
parent = next(
(plugin for plugin in plugins if plugin.pk == target_plugin.parent_id), None
) if target_plugin.parent_id else None
content["content"] = get_plugin_content(request, downcasted, {"parent": parent})
return {'html': '\n'.join(tree_structure), 'plugins': tree_data, **content}
def get_plugin_content(request: HttpRequest, plugin: CMSPlugin | list[CMSPlugin], context: dict | None = None) -> list[dict[str, Any]]:
if context is None:
context = {}
plugin_list = plugin if isinstance(plugin, list) else [plugin]
toolbar = get_toolbar_from_request(request)
renderer = toolbar.content_renderer
# Switch to edit mode despite the request originally coming from the admin
toolbar.edit_mode_active = True
renderer._placeholders_are_editable = True
sekizai_context = SekizaiContext({'request': request, **context})
try:
with force_language(plugin_list[0].language):
return [{
"html": renderer.render_plugin(plugin, sekizai_context, placeholder=plugin.placeholder, editable=True),
"js": "".join(sekizai_context[get_varname()].get("js", [])),
"css": "".join(sekizai_context[get_varname()].get("css", [])),
"position": plugin.position,
"placeholder_id": plugin.placeholder_id,
"pluginIds": get_plugin_tree_ids(plugin),
} for plugin in plugin_list]
except Exception:
return [] # do not deliver content if rendering fails
def get_plugin_tree_ids(plugin: CMSPlugin) -> list[int]:
plugin_ids = [plugin.pk]
for child in plugin.child_plugin_instances or []:
plugin_ids += get_plugin_tree_ids(child)
return plugin_ids
def get_toolbar_from_request(request: HttpRequest):
from .toolbar import EmptyToolbar
return getattr(request, 'toolbar', EmptyToolbar(request))
def add_live_url_querystring_param(obj: models.Model, url: str, language: str | None = None) -> str:
"""
Append a live url to a given object url using a supplied url parameter configured
by the setting: CMS_ENDPOINT_LIVE_URL_QUERYSTRING_PARAM
:param obj: Placeholder source object
:param url: Url string
:param language: The current language code or None
:returns: A url string
"""
url_param = get_cms_setting('ENDPOINT_LIVE_URL_QUERYSTRING_PARAM')
if not hasattr(obj, "get_absolute_url"):
return url
try:
live_url = obj.get_absolute_url()
except NoReverseMatch:
return url
url_fragments = url.split('?')
if len(url_fragments) > 1:
url += f'&{url_param}={live_url}'
else:
url += f'?{url_param}={live_url}'
return url
def _get_object_url(reverse_name: str, obj: models.Model, language: str = None, params: QueryDict | None = None) -> str:
content_type = ContentType.objects.get_for_model(obj)
language = getattr(obj, "language", language) # Object trumps parameter
if language is None:
language = get_language()
with force_language(language):
url = admin_reverse(reverse_name, args=[content_type.pk, obj.pk])
get_params = params.copy() if params else QueryDict(mutable=True)
if not get_language_from_path(url):
get_params["language"] = language
if get_params:
url = url + "?" + get_params.urlencode()
if get_cms_setting("ENDPOINT_LIVE_URL_QUERYSTRING_PARAM_ENABLED"):
url = add_live_url_querystring_param(obj, url, language)
return url
def get_object_edit_url(obj: models.Model, language: str | None = None, params: QueryDict | None = None) -> str:
"""
Returns the url of the edit endpoint for the given object. The object must be frontend-editable
and registered as such with cms.
If the object has a language property, the language parameter is ignored.
params is an optional QueryDict to be added to the URL.
"""
return _get_object_url("cms_placeholder_render_object_edit", obj, language, params)
def get_object_preview_url(obj: models.Model, language: str | None = None, params: QueryDict | None = None) -> str:
"""
Returns the url of the preview endpoint for the given object. The object must be frontend-editable
and registered as such with cms.
If the object has a language property, the language parameter is ignored.
params is an optional QueryDict to be added to the URL.
"""
return _get_object_url("cms_placeholder_render_object_preview", obj, language, params)
def get_object_structure_url(obj: models.Model, language: str | None = None, params: QueryDict | None = None) -> str:
"""
Returns the url of the structure endpoint for the given object. The object must be frontend-editable
and registered as such with cms.
If the object has a language property, the language parameter is ignored.
params is an optional QueryDict to be added to the URL.
"""
return _get_object_url("cms_placeholder_render_object_structure", obj, language, params)
def get_object_live_url(obj: models.Model, language: str | None = None, site: Site | None = None, params: QueryDict | None = None) -> str | None:
"""
Returns the live url of the given object. The object must be frontend-editable
and registered as such with cms.
If the object has a language property, the language parameter is ignored.
If the object - or its grouper - has no site property, the site argument is ignored.
"""
if not hasattr(obj, "get_absolute_url"):
return None
language = getattr(obj, "language", language) # Object trumps parameter
if language is None:
language = get_language()
with force_language(language):
absolute_url = obj.get_absolute_url()
if not absolute_url:
return None
url_param = get_cms_setting("ENDPOINT_LIVE_URL_QUERYSTRING_PARAM")
if params and url_param in params:
params = params.copy()
del params[url_param]
if params and "?" not in absolute_url:
absolute_url = absolute_url + "?" + params.urlencode()
obj_site = getattr(obj, 'site', None)
if obj_site is None:
try:
grouper_field = apps.get_app_config('cms').cms_extension.model_groupers[obj.__class__]
obj_site = getattr(getattr(obj, grouper_field, None), 'site', None)
except KeyError:
pass
if obj_site and obj_site != site:
# Add domain if current and target sites are defined and different
absolute_url = f"//{obj_site.domain}{absolute_url}"
return absolute_url
def get_object_for_language(obj: models.Model, language: str, latest: bool = False) -> models.Model | None:
"""
Retrieves the correct content object for the target language. The object must be frontend-editable
and registered as such with cms.
Two cases have to be distinguished:
1. **Object has a language property:** If the language of the passed object is different,
sibling objects are retrieved from the database and cached in the object passed.
2. **Object has no language property:** The placeholders of the object contain the different
language content. The object itself is returned
"""
if getattr(obj, "language", language) == language:
# Object does not have language field or language is requested language
# Return object itself
return obj
if isinstance(obj, PageContent):
return obj.page.get_admin_content(language, fallback=False) or None
# Does the object have a cache with sister objects
cached_object = getattr(obj, "_sibling_objects_for_language_cache", {})
if cached_object:
return cached_object.get(language)
extension = apps.get_app_config('cms').cms_extension
model = obj.__class__
field = extension.model_groupers.get(model)
if not field:
# Cannot infer sister object if grouper field is unknown
return None
# Grouper model not registered or does not have a get_content_obj method,
# or get_content_obj does not accept language parameter
# Now query db
grouper_filter = {field: getattr(obj, field)}
qs = model.admin_manager.latest_content() if latest and hasattr(model, "admin_manager") else model.objects
obj._sibling_objects_for_language_cache = {
result.language: result for result in qs.filter(**grouper_filter)
}
return obj._sibling_objects_for_language_cache.get(language)