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 `, #: 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