dynamicweb/cms/toolbar/toolbar.py

648 lines
24 KiB
Python

import functools
import operator
from collections import OrderedDict
from urllib.parse import urlparse
from classytags.utils import flatten_context
from django.apps import apps
from django.conf import settings
from django.middleware.csrf import get_token
from django.template.loader import render_to_string
from django.urls import NoReverseMatch, Resolver404, resolve
from django.utils.functional import cached_property
from django.utils.translation import override as force_language
from cms import __version__
from cms.constants import LEFT, REFRESH_PAGE
from cms.forms.login import CMSToolbarLoginForm
from cms.models import Placeholder, UserSettings
from cms.templates import TemplatesCache
from cms.toolbar.items import ButtonList, Menu, ToolbarAPIMixin
from cms.toolbar.utils import (
get_object_edit_url,
get_object_preview_url,
get_object_structure_url,
)
from cms.toolbar_pool import toolbar_pool
from cms.utils import get_language_from_request
from cms.utils.compat import DJANGO_VERSION, PYTHON_VERSION
from cms.utils.compat.dj import installed_apps
from cms.utils.conf import get_cms_setting
from cms.utils.i18n import get_site_language_from_request
from cms.utils.urlutils import admin_reverse
cms_toolbar_extensions = apps.get_app_config('cms').cms_extension.toolbar_mixins
class BaseToolbar(ToolbarAPIMixin):
#: Property; a list of models that the toolbar :ref:`watches for URL changes <url_changes>`,
#: so it can redirect to the new URL on saving.
watch_models = []
disable_url = get_cms_setting('CMS_TOOLBAR_URL__DISABLE')
@cached_property
def _resolver_match(self):
if getattr(self.request, 'resolver_match', None):
return self.request.resolver_match
try:
if hasattr(self, "request_path"):
match = resolve(self.request_path)
else:
match = resolve(self.request.path_info)
except Resolver404:
match = None
return match
@cached_property
def site_language(self):
cms_page = self.request.current_page
site_id = cms_page.site_id if cms_page else None
return get_site_language_from_request(self.request, site_id)
@cached_property
def request_language(self):
if settings.USE_I18N:
language = get_language_from_request(self.request)
else:
language = settings.LANGUAGE_CODE
return language
def get_content_renderer(self):
if self.uses_legacy_structure_mode:
return self.legacy_renderer
return self.content_renderer
@cached_property
def legacy_renderer(self):
from cms.plugin_rendering import LegacyRenderer
return LegacyRenderer(request=self.request)
@cached_property
def content_renderer(self):
from cms.plugin_rendering import ContentRenderer
return ContentRenderer(request=self.request)
@cached_property
def structure_renderer(self):
from cms.plugin_rendering import StructureRenderer
return StructureRenderer(request=self.request)
@cached_property
def structure_mode_active(self):
if self.is_staff and self._resolver_match:
return self._resolver_match.url_name == 'cms_placeholder_render_object_structure'
return False
@cached_property
def edit_mode_active(self):
"""``True`` if the structure board editing mode is active."""
if not self.show_toolbar:
return False
if self.structure_mode_active:
return True
if self.is_staff and self._resolver_match:
return self._resolver_match.url_name == 'cms_placeholder_render_object_edit'
return False
@cached_property
def preview_mode_active(self):
"""``True`` if preview mode is active."""
if self.is_staff and self._resolver_match:
return self._resolver_match.url_name == 'cms_placeholder_render_object_preview'
return False
@cached_property
def content_mode_active(self):
"""``True`` if content mode is active."""
return self.is_staff and not self.edit_mode_active
@cached_property
def uses_legacy_structure_mode(self):
return False
@cached_property
def templates(self):
return TemplatesCache()
@cached_property
def sideframe_enabled(self):
return get_cms_setting('SIDEFRAME_ENABLED')
class CMSToolbarBase(BaseToolbar):
"""
The toolbar is an instance of the :class:`~cms.toolbar.toolbar.CMSToolbar` class. This should not be
confused with the :class:`~cms.toolbar_base.CMSToolbar`, the base class for *toolbar modifier
classes* in other applications, that add items to and otherwise manipulates the toolbar.
It is strongly recommended that you **only** interact with the toolbar in your own code via:
#. the APIs documented here
#. toolbar modifier classes based on :class:`~cms.toolbar_base.CMSToolbar`
Several of the following methods to create and add objects other objects to the toolbar are
inherited from :class:`~cms.toolbar.items.ToolbarAPIMixin`.
"""
def __init__(self, request, request_path=None, _async=False):
super().__init__()
self._async = _async
self.right_items = []
self.left_items = []
self.last_left_items = []
self.last_right_items = []
self.populated = False
self.post_template_populated = False
self.menus = {}
self.obj = None
self.redirect_url = None
self.request = None
self.is_staff = None
self.clipboard = None
self.toolbar_language = None
self.toolbar_language_bidi = None
self.show_toolbar = True
self.init_toolbar(request, request_path=request_path)
# Internal attribute to track whether we can cache
# a response from the current request.
# This attribute is modified by the placeholder rendering
# mechanism in case a placeholder rendered by the current
# request cannot be cached.
self._cache_disabled = self.edit_mode_active or self.show_toolbar
with force_language(self.request_language):
try:
decorator = resolve(self.request_path).func
try:
# If the original view is decorated we try to extract the real function
# module instead of the decorator's one
if decorator and getattr(decorator, '__closure__', False):
self.app_name = decorator.__closure__[0].cell_contents.__module__
else:
raise AttributeError()
except (TypeError, AttributeError):
# no decorator
self.app_name = decorator.__module__
except (Resolver404, AttributeError):
self.app_name = ""
toolbars = toolbar_pool.get_toolbars()
parts = self.app_name.split('.')
while parts:
path = '.'.join(parts)
if path in installed_apps():
self.app_name = path
break
parts.pop()
self.toolbars = OrderedDict()
for key in toolbars:
is_current_app = toolbars[key].check_current_app(key, self.app_name)
toolbar = toolbars[key](
request=self.request,
toolbar=self,
is_current_app=is_current_app,
app_path=self.app_name,
)
self.toolbars[key] = toolbar
@staticmethod
def _get_request_path(request):
# The viewed page path, reported via the ``cms_path`` GET query on
# asynchronous toolbar/structure requests, falls back to the actual
# request path for regular page requests.
cms_path = request.GET.get('cms_path')
if cms_path:
return urlparse(cms_path).path
return request.path_info
def init_toolbar(self, request, request_path=None):
self.request = request
# ``request_path`` is the path of the page the user is *viewing*. For regular
# page requests this is identical to ``request.path_info``. For asynchronous
# toolbar/structure requests, however, ``request.path_info`` points at an
# internal admin/AJAX endpoint while the viewed page is reported via the
# ``cms_path`` GET query. Honour ``cms_path`` so that everything relying on
# ``request_path`` (e.g. the language menu) builds URLs for the viewed page.
self.request_path = request_path or self._get_request_path(request)
self.is_staff = self.request.user.is_staff
self.show_toolbar = self.is_staff
anonymous_on = get_cms_setting('TOOLBAR_ANONYMOUS_ON')
enable_toolbar = get_cms_setting('CMS_TOOLBAR_URL__ENABLE')
disable_toolbar = get_cms_setting('CMS_TOOLBAR_URL__DISABLE')
try:
admin_reverse('index')
# Handle showing the toolbar for anonymous users when they supply
# the enable toolbar parameter
if (anonymous_on and request.user.is_anonymous) and enable_toolbar in self.request.GET:
self.show_toolbar = True
if self.show_toolbar:
edit_mode = (
self._resolver_match
and self._resolver_match.url_name == 'cms_placeholder_render_object_edit'
)
if enable_toolbar in self.request.GET or edit_mode:
self.show_toolbar = True
elif disable_toolbar in self.request.GET:
self.show_toolbar = False
elif self.request.session.get('cms_toolbar_disabled', False):
self.show_toolbar = False
except NoReverseMatch:
# No admin on this site - disable toolbar
self.show_toolbar = False
# We need to store the current language in case the user's preferred language is different.
self.toolbar_language = self.request_language
if self.is_staff:
user_settings = self.user_settings
if (settings.USE_I18N and user_settings.language in dict(settings.LANGUAGES)) or (
not settings.USE_I18N and user_settings.language == settings.LANGUAGE_CODE):
self.toolbar_language = user_settings.language
else:
user_settings.language = self.request_language
user_settings.save()
self.clipboard = user_settings.clipboard
self.toolbar_language_bidi = self.toolbar_language in settings.LANGUAGES_BIDI
if hasattr(self, 'toolbars'):
for key, toolbar in self.toolbars.items():
self.toolbars[key].request = self.request
@cached_property
def user_settings(self):
return self.get_user_settings()
@cached_property
def clipboard_plugin(self):
if not self.clipboard:
return None
try:
plugin = self.clipboard.get_plugins().select_related('placeholder')[0]
except IndexError:
bound_plugin = None
else:
bound_plugin = plugin.get_bound_plugin()
return bound_plugin
def get_user_settings(self):
user_settings = None
if self.is_staff:
try:
user_settings = UserSettings.objects.select_related('clipboard').get(user=self.request.user)
if user_settings.clipboard and not user_settings.clipboard.object_id:
# Add source field to existing clipboard objects
user_settings.clipboard.source = user_settings
user_settings.clipboard.save()
except UserSettings.DoesNotExist:
placeholder = Placeholder.objects.create(slot="clipboard")
user_settings = UserSettings.objects.create(
clipboard=placeholder,
language=self.request_language,
user=self.request.user,
)
placeholder.source = user_settings # Populate source
placeholder.save()
return user_settings
def _reorder_toolbars(self):
from cms.cms_toolbars import BasicToolbar
toolbars = list(self.toolbars.values())
basic_toolbar = [toolbar for toolbar in toolbars if toolbar.__class__ == BasicToolbar]
if basic_toolbar and basic_toolbar[0] in toolbars:
toolbars.remove(basic_toolbar[0])
toolbars.insert(0, basic_toolbar[0])
return toolbars
@property
def csrf_token(self):
token = get_token(self.request)
return token
# Public API
def get_menu(self, key, verbose_name=None, side=LEFT, position=None):
"""Will return the ``Menu`` identified with :option:`key`, or ``None``."""
self.populate()
if key in self.menus:
return self.menus[key]
return None
def get_or_create_menu(self, key, verbose_name=None, disabled=False, side=LEFT, position=None):
"""
If a :class:`~cms.toolbar.items.Menu` with :option:`key` already exists, this method will
return that menu. Otherwise it will create a menu with the ``key`` identifier.
"""
self.populate()
if key in self.menus:
menu = self.menus[key]
if verbose_name:
menu.name = verbose_name
if menu.side != side:
menu.side = side
if position:
self.remove_item(menu)
self.add_item(menu, position=position)
return menu
menu = Menu(verbose_name, self.csrf_token, disabled=disabled, side=side)
self.menus[key] = menu
self.add_item(menu, position=position)
return menu
def add_button(self, name, url, active=False, disabled=False, extra_classes=None, extra_wrapper_classes=None,
side=LEFT, position=None):
"""Adds a :class:`~cms.toolbar.items.Button` to the toolbar."""
self.populate()
item = ButtonList(extra_classes=extra_wrapper_classes, side=side)
item.add_button(name, url, active=active, disabled=disabled, extra_classes=extra_classes)
self.add_item(item, position=position)
return item
def add_modal_button(self, name, url, active=False, disabled=False, extra_classes=None, extra_wrapper_classes=None,
side=LEFT, position=None, on_close=REFRESH_PAGE):
"""Adds a :class:`~cms.toolbar.items.ModalButton` to the toolbar."""
self.populate()
item = ButtonList(extra_classes=extra_wrapper_classes, side=side)
item.add_modal_button(
name, url, active=active, disabled=disabled, extra_classes=extra_classes, on_close=on_close
)
self.add_item(item, position=position)
return item
def add_sideframe_button(self, name, url, active=False, disabled=False, extra_classes=None,
extra_wrapper_classes=None, side=LEFT, position=None, on_close=None):
"""Adds a :class:`~cms.toolbar.items.SideframeButton` to the toolbar."""
self.populate()
item = ButtonList(extra_classes=extra_wrapper_classes, side=side)
item.add_sideframe_button(
name, url, active=active, disabled=disabled, extra_classes=extra_classes, on_close=on_close
)
self.add_item(item, position=position)
return item
def add_button_list(self, identifier=None, extra_classes=None, side=LEFT, position=None):
"""Adds an (empty) :class:`~cms.toolbar.items.ButtonList` to the toolbar and returns it."""
self.populate()
item = ButtonList(identifier, extra_classes=extra_classes, side=side)
self.add_item(item, position=position)
return item
def set_object(self, obj):
"""
Associates an object with the toolbar.
Sets the toolbar's object if one has not already been set. This object is typically
a Django model instance that the toolbar should operate on, such as a :class:`~cms.models.contentmodels.PageContent` object or any
other model that supports editable placeholders through a :class:`~cms.models.fields.PlaceholderRelationField`.
The object is used by other toolbar methods like
``get_object_edit_url()``, ``get_object_preview_url()``, and
``get_object_structure_url()`` to generate
appropriate URLs for the object.
:param obj: The object to associate with the toolbar
:type obj: django.db.models.Model
"""
if not self.obj:
self.obj = obj
def get_object(self):
"""
Returns the object currently associated with the toolbar.
This returns the object that was previously set using :meth:`set_object`,
or ``None`` if no object has been associated with the toolbar.
:returns: The object associated with the toolbar, or None
:rtype: django.db.models.Model or None
"""
return self.obj
def get_object_model(self):
if self.obj:
return f"{self.obj._meta.app_label}.{self.obj._meta.object_name}".lower()
return ''
def get_object_pk(self):
if self.obj:
return self.obj.pk
return ''
def get_object_preview_url(self):
if self.obj:
return get_object_preview_url(self.obj, language=self.request_language, params=self.request.GET)
return ''
def get_object_edit_url(self):
if self.obj:
return get_object_edit_url(self.obj, language=self.request_language, params=self.request.GET)
return ''
def get_object_structure_url(self):
if self.obj:
return get_object_structure_url(self.obj, language=self.request_language, params=self.request.GET)
return ''
def object_is_editable(self, obj=None):
obj = obj or self.obj
if obj:
if hasattr(obj, "is_editable"):
# Object can decide itself
return obj.is_editable(self.request)
return True
return False
# Internal API
def _add_item(self, item, position=None):
item.toolbar = self
if item.right:
if position and position < 0:
target = self.last_right_items
position = abs(position)
else:
target = self.right_items
else:
if position and position < 0:
target = self.last_left_items
position = abs(position)
else:
target = self.left_items
if position is not None:
target.insert(position, item)
else:
target.append(item)
def _remove_item(self, item):
if item in self.right_items:
self.right_items.remove(item)
elif item in self.last_right_items:
self.last_right_items.remove(item)
elif item in self.left_items:
self.left_items.remove(item)
elif item in self.last_left_items:
self.last_left_items.remove(item)
else:
raise KeyError("Item %r not found" % item)
def _item_position(self, item):
if item.right:
return self.right_items.index(item)
else:
return self.left_items.index(item)
def get_left_items(self):
self.populate()
items = self.left_items + list(reversed(self.last_left_items))
return items
def get_right_items(self):
self.populate()
items = self.right_items + list(reversed(self.last_right_items))
return items
@cached_property
def media(self):
self.populate()
toolbars = self.toolbars.values()
return functools.reduce(operator.add, (toolbar.media for toolbar in toolbars))
def populate(self):
"""
Populates the toolbar with the CMS items.
"""
if self.populated:
return
self.populated = True
# never populate the toolbar on is_staff=False
# FIXME: In 3.1 we should really update the request/staff status
# when toolbar is used in the cms_toolbar templatetag
if not self.request.user.is_staff:
return
self._call_toolbar('populate')
def post_template_populate(self):
self.populate()
if self.post_template_populated:
return
self.post_template_populated = True
# FIXME: In 3.1 we should really update the request/staff status
# when toolbar is used in the cms_toolbar templatetag
if not self.request.user.is_staff:
return
self._call_toolbar('post_template_populate')
def _call_toolbar(self, func_name):
with force_language(self.toolbar_language):
first = ('cms.cms_toolbars.BasicToolbar', 'cms.cms_toolbars.PlaceholderToolbar')
for key in first:
toolbar = self.toolbars.get(key)
if not toolbar:
continue
getattr(toolbar, func_name)()
for key in self.toolbars:
if key in first:
continue
toolbar = self.toolbars[key]
getattr(toolbar, func_name)()
def get_render_context(self):
if self.structure_mode_active and not self.uses_legacy_structure_mode:
# User has explicitly requested structure mode
# and the object (page, blog, etc..) allows for the non-legacy structure mode
renderer = self.structure_renderer
else:
renderer = self.get_content_renderer()
context = {
'cms_toolbar': self,
'cms_renderer': renderer,
'cms_edit_url': self.get_object_edit_url(),
'cms_preview_url': self.get_object_preview_url(),
'cms_structure_url': self.get_object_structure_url(),
'cms_version': __version__,
'django_version': DJANGO_VERSION,
'login_form': CMSToolbarLoginForm(),
'python_version': PYTHON_VERSION,
}
return context
def render(self):
self.populate()
self.post_template_populate()
context = self.get_render_context()
with force_language(self.toolbar_language):
return render_to_string('cms/toolbar/toolbar.html', context, request=self.request)
def render_with_structure(self, context, nodelist):
self.populate()
context.update(self.get_render_context())
with force_language(self.toolbar_language):
# needed to populate the context with sekizai content
render_to_string('cms/toolbar/toolbar_javascript.html', flatten_context(context))
# render everything below the tag
rendered_contents = nodelist.render(context)
self.post_template_populate()
with force_language(self.toolbar_language):
# render the toolbar content
toolbar = render_to_string('cms/toolbar/toolbar_with_structure.html', flatten_context(context))
# return the toolbar content and the content below
return f'{toolbar}\n{rendered_contents}'
#: :class:`CMSToolbarBase` including toolbar mixins from extensions to toolbar
CMSToolbar = type("CMSToolbar", tuple(cms_toolbar_extensions + [CMSToolbarBase]), dict())
class EmptyToolbar(BaseToolbar):
is_staff = False
show_toolbar = False
# Backwards compatibility
edit_mode = False
_cache_disabled = True
#: No toolbar means no user settings, hence no clipboard. Mirrors the initial
#: value on ``CMSToolbarBase`` so that callers can rely on the attribute
#: existing and only have to handle ``None``.
clipboard = None
def __init__(self, request):
self.request = request
super().__init__()
@cached_property
def toolbar_language(self):
# There are no user settings to take a preferred language from, so the
# request language is the best available answer.
return self.request_language
@cached_property
def toolbar_language_bidi(self):
return self.toolbar_language in settings.LANGUAGES_BIDI
def get_object(self):
return None