import json import uuid import warnings from urllib.parse import parse_qsl, urlparse from django.contrib import admin from django.contrib.admin.helpers import AdminForm from django.contrib.admin.options import IS_POPUP_VAR from django.contrib.admin.utils import flatten_fieldsets, get_deleted_objects from django.core.exceptions import PermissionDenied from django.db import models, transaction from django.http import ( HttpResponse, HttpResponseBadRequest, HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseNotFound, ) from django.shortcuts import get_list_or_404, get_object_or_404 from django.template.response import TemplateResponse from django.urls import include, re_path from django.utils.datastructures import MultiValueDict from django.utils.decorators import method_decorator from django.utils.encoding import force_str from django.utils.html import conditional_escape from django.utils.translation import get_language_from_path, gettext as _ from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST from cms import operations from cms.admin.forms import PluginAddValidationForm from cms.constants import SLUG_REGEXP from cms.exceptions import PluginLimitReached from cms.models.placeholdermodel import Placeholder from cms.models.placeholderpluginmodel import PlaceholderReference from cms.models.pluginmodel import CMSPlugin from cms.plugin_base import CMSPluginBase from cms.plugin_pool import plugin_pool from cms.signals import post_placeholder_operation, pre_placeholder_operation from cms.toolbar.utils import ( create_child_plugin_references, get_plugin_content, get_plugin_tree, get_toolbar_from_request, ) from cms.utils import get_current_site from cms.utils.conf import get_cms_setting from cms.utils.i18n import get_language_code, get_language_list from cms.utils.plugins import ( copy_plugins_to_placeholder, downcast_plugins, get_plugin_disallowed_in_slot, has_reached_plugin_limit, ) from cms.views import ( render_object_edit, render_object_preview, render_object_structure, ) _no_default = object() def get_int(int_str, default=_no_default): """ For convenience a get-like method for taking the int() of a string. :param int_str: the string to convert to integer :param default: an optional value to return if ValueError is raised. :return: the int() of «int_str» or «default» on exception. """ if default == _no_default: return int(int_str) else: try: return int(int_str) except ValueError: return default def _instance_overrides_method(base, instance, method_name): """ Returns True if instance overrides a method (method_name) inherited from base. """ bound_method = getattr(instance.__class__, method_name) unbound_method = getattr(base, method_name) return unbound_method != bound_method class BaseEditableAdminMixin: """ Base class for FrontendEditableAdminMixin to be reused by PlaceholderAdmin """ @xframe_options_sameorigin def edit_field(self, request, object_id, language): """Endpoint which manages frontend-editable fields""" obj = self._get_object_for_single_field(object_id, language) opts = obj.__class__._meta saved_successfully = False cancel_clicked = request.POST.get("_cancel", False) raw_fields = request.GET.get("edit_fields", "") admin_obj, change_permission = self._get_model_admin_and_permission(request, obj) allowed_fields = getattr(admin_obj, "frontend_editable_fields", []) fields = [field for field in raw_fields.split(",") if field in allowed_fields] if not fields: context = { 'opts': opts, 'message': _("Field %s not found") % raw_fields } return TemplateResponse(request, 'admin/cms/page/plugin/error_form.html', context) if not change_permission: context = { 'opts': opts, 'message': _("You do not have permission to edit this item") } return TemplateResponse(request, 'admin/cms/page/plugin/error_form.html', context) # Dynamically creates the form class with only `field_name` field # enabled form_class = admin_obj.get_form(request, obj, fields=fields) if not cancel_clicked and request.method == 'POST': form = form_class(instance=obj, data=request.POST) if form.is_valid(): new_object = form.save(commit=False) admin_obj.save_model(request, new_object, form, change=True) # Call save model like the admin does saved_successfully = True else: form = form_class(instance=obj) admin_form = AdminForm(form, fieldsets=[(None, {'fields': fields})], prepopulated_fields={}, model_admin=self) media = admin_obj.media + admin_form.media context = { 'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'), 'title': opts.verbose_name, 'plugin': None, 'plugin_id': None, 'adminform': admin_form, 'add': False, 'is_popup': True, 'media': media, 'opts': opts, 'change': True, 'save_as': False, 'has_add_permission': False, 'window_close_timeout': 10, } if cancel_clicked: # cancel button was clicked context["cancel"] = True return TemplateResponse(request, 'admin/cms/page/plugin/confirm_form.html', context) if not cancel_clicked and request.method == 'POST' and saved_successfully and isinstance(admin_obj, CMSPluginBase): # Update the structure board by populating the data bridge return admin_obj.render_close_frame(request, obj, action="change") return TemplateResponse(request, 'admin/cms/page/plugin/change_form.html', context) class FrontendEditableAdminMixin(BaseEditableAdminMixin): """ Adding ``FrontendEditableAdminMixin`` to models admin class allows to open that admin in the frontend by double-clicking on fields rendered with the ``render_model`` template tag. """ def get_urls(self) -> list[str]: """ Register the url for the edit field view """ info = f"{self.model._meta.app_label}_{self.model._meta.model_name}" def pat(regex, fn): return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}") url_patterns = [ pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field), ] return url_patterns + super().get_urls() def _get_model_admin_and_permission(self, request, obj: models.Model) -> tuple[admin.ModelAdmin, bool]: # FrontendEditableAdminMixin needs to be added to the model's model admin class. # Hence, the relevant admin is the model admin itself. change_permission = request.user.has_perm(f"{obj._meta.app_label}.change_{obj._meta.model_name}", obj=obj) return self, change_permission def _get_object_for_single_field(self, object_id: int, language: str) -> models.Model: # Quick and dirty way to retrieve objects for django-hvad # Cleaner implementation will extend this method in a child mixin try: # First see if the model uses the admin manager pattern from cms.models.manager.ContentAdminManager manager = self.model.admin_manager except AttributeError: # If not, use the default manager manager = self.model.objects try: return manager.language(language).get(pk=object_id) except AttributeError: return manager.get(pk=object_id) @admin.register(Placeholder) class PlaceholderAdmin(BaseEditableAdminMixin, admin.ModelAdmin): """Placeholder admin manages placeholders and their plugins, as well as the preview, edit, and structure endpoints.""" def has_add_permission(self, request): # Placeholders are created by the system return False def has_change_permission(self, request, obj=None): # Placeholders are not editable in the admin return False def has_delete_permission(self, request, obj=None): # Placeholders are deleted by cascading the deletion of their source object # so we don't need to check for delete permissions here. return True def has_module_permission(self, request): # Do not show in admin return False def delete_view(self, request, object_id, extra_context=None): # Placeholder are deleted by cascading the deletion of their source object # but the admin's delete view is not available for placeholders. raise PermissionDenied def get_urls(self) -> list[str]: """ Register the plugin specific urls (add/edit/copy/remove/move) """ info = f"{self.model._meta.app_label}_{self.model._meta.model_name}" def pat(regex, fn): return re_path(regex, self.admin_site.admin_view(fn), name=f"{info}_{fn.__name__}") url_patterns = [ re_path(r'^cms_wizard/', include('cms.wizards.urls')), pat(r'^copy-plugins/$', self.copy_plugins), pat(r'^add-plugin/$', self.add_plugin), pat(r'^edit-plugin/([0-9]+)/$', self.edit_plugin), pat(r'^edit-field/([0-9]+)/([a-z\-]+)/$', self.edit_field), pat(r'^delete-plugin/([0-9]+)/$', self.delete_plugin), pat(r'^clear-placeholder/([0-9]+)/$', self.clear_placeholder), pat(r'^move-plugin/$', self.move_plugin), # Register object edit/structure/preview endpoints. pat(r'^object/([0-9]+)/edit/([0-9]+)/$', render_object_edit), pat(r'^object/([0-9]+)/structure/([0-9]+)/$', render_object_structure), pat(r'^object/([0-9]+)/preview/([0-9]+)/$', render_object_preview), ] return url_patterns def _get_object_for_single_field(self, object_id: int, language: str) -> CMSPlugin: # For BaseEditableAdminMixin: This (private) method retrieves the corresponding CMSPlugin and # downcasts it to the appropriate plugin model. language is ignored. This provides the plugin for # edit_field""" plugin = get_object_or_404(CMSPlugin, pk=object_id) # Returns a CMSPlugin instance return plugin.get_bound_plugin() # Returns the plugin model instance of the appropriate type def _get_model_admin_and_permission(self, request, obj: CMSPlugin) -> tuple[admin.ModelAdmin, bool]: # For BaseEditableAdminMixin: This (private) method retrieves the model admin for the plugin model # which is the plugin instance itself, and checks change permissions including check_source placeholder = obj.placeholder has_permission = ( placeholder.has_change_plugin_permission(request.user, obj) and placeholder.check_source(request.user) ) return obj.get_plugin_class_instance(admin=self.admin_site), has_permission def _get_operation_language(self, request): # Unfortunately the ?language GET query # has a special meaning on the CMS. # It allows users to see another language while maintaining # the same url. This complicates language detection. site = get_current_site(request) parsed_url = urlparse(request.GET['cms_path']) queries = dict(parse_qsl(parsed_url.query)) language = queries.get('language') if not language: language = get_language_from_path(parsed_url.path) return get_language_code(language, site_id=site.pk) def _get_operation_origin(self, request): return urlparse(request.GET['cms_path']).path def _send_pre_placeholder_operation(self, request, operation, **kwargs): token = str(uuid.uuid4()) if not request.GET.get('cms_path'): warnings.warn('All custom placeholder admin endpoints require ' 'a "cms_path" GET query which points to the path ' 'where the request originates from.' 'This backwards compatible shim will be removed on 3.5 ' 'and an HttpBadRequest response will be returned instead.', UserWarning) return token pre_placeholder_operation.send( sender=self.__class__, operation=operation, request=request, language=self._get_operation_language(request), token=token, origin=self._get_operation_origin(request), **kwargs ) return token def _send_post_placeholder_operation(self, request, operation, token, **kwargs): if not request.GET.get('cms_path'): # No need to re-raise the warning return post_placeholder_operation.send( sender=self.__class__, operation=operation, request=request, language=self._get_operation_language(request), token=token, origin=self._get_operation_origin(request), **kwargs ) def _get_plugin_from_id(self, plugin_id): queryset = CMSPlugin.objects.values_list('plugin_type', flat=True) plugin_type = get_list_or_404(queryset, pk=plugin_id)[0] # CMSPluginBase subclass plugin_class = plugin_pool.get_plugin(plugin_type) real_queryset = plugin_class.get_render_queryset().select_related('parent', 'placeholder') return get_object_or_404(real_queryset, pk=plugin_id) def has_add_plugin_permission(self, request, placeholder, plugin_type): return placeholder.has_add_plugin_permission(request.user, plugin_type) def has_change_plugin_permission(self, request, plugin): placeholder = plugin.placeholder return placeholder.has_change_plugin_permission(request.user, plugin) def has_delete_plugin_permission(self, request, plugin): placeholder = plugin.placeholder return placeholder.has_delete_plugin_permission(request.user, plugin) def has_copy_plugins_permission(self, request, plugins): # Plugins can only be copied to the clipboard placeholder = get_toolbar_from_request(request).clipboard if placeholder is None: # No toolbar, no clipboard to copy into. return False return placeholder.has_add_plugins_permission(request.user, plugins) def has_copy_from_clipboard_permission(self, request, placeholder, plugins): return placeholder.has_add_plugins_permission(request.user, plugins) def has_paste_from_source_permission(self, request, source_placeholder, plugins): # Pasting reads the plugins from the placeholder they are pasted from, # so a user without access to that placeholder must not be able to # exfiltrate its content into a placeholder they do control. clipboard = get_toolbar_from_request(request).clipboard if clipboard and source_placeholder.pk == clipboard.pk: # Pasting from the user's own clipboard, which is what this # operation is for. A user may always read their own clipboard. return True # Deliberately no ``source_placeholder.check_source()`` here: that is an # editability gate (see ``placeholder_is_immutable``), and pasting only # reads the source. Editors must be able to paste out of content they # may not edit, such as a published version or a draft another user has # locked. Read access is covered by the permission check below. return source_placeholder.has_add_plugins_permission(request.user, plugins) def has_copy_from_placeholder_permission(self, request, source_placeholder, target_placeholder, plugins): if not source_placeholder.has_add_plugins_permission(request.user, plugins): return False return target_placeholder.has_add_plugins_permission(request.user, plugins) def has_move_plugin_permission(self, request, plugin, target_placeholder): placeholder = plugin.placeholder return placeholder.has_move_plugin_permission(request.user, plugin, target_placeholder) def has_clear_placeholder_permission(self, request, placeholder, language=None): if language: languages = [language] else: # fetch all languages this placeholder contains # based on it's plugins languages = ( placeholder .cmsplugin_set .values_list('language', flat=True) .distinct() .order_by() ) return placeholder.has_clear_permission(request.user, languages) def get_placeholder_template(self, request, placeholder): pass @xframe_options_sameorigin @transaction.atomic def add_plugin(self, request): """ Shows the add plugin form and saves it on POST. Requires the following GET parameters: - cms_path - placeholder_id - plugin_type - plugin_language - plugin_position - plugin_parent (optional) """ form = PluginAddValidationForm(request.GET) form._request = request if not form.is_valid(): # list() is necessary for python 3 compatibility. # errors is s dict mapping fields to a list of errors # for that field. error = list(form.errors.values())[0][0] return HttpResponseBadRequest(conditional_escape(force_str(error))) plugin_data = form.cleaned_data placeholder = plugin_data['placeholder_id'] plugin_type = plugin_data['plugin_type'] if not self.has_add_plugin_permission(request, placeholder, plugin_type): message = _('You do not have permission to add a plugin') return HttpResponseForbidden(message) if not placeholder.check_source(request.user): message = _('You do not have permission to add a plugin') return HttpResponseForbidden(message) plugin_class = plugin_pool.get_plugin(plugin_type) plugin_instance = plugin_class(plugin_class.model, self.admin_site) # Setting attributes on the form class is perfectly fine. # The form class is created by modelform factory every time # this get_form() method is called. plugin_instance._cms_initial_attributes = { 'language': plugin_data['plugin_language'], 'placeholder': plugin_data['placeholder_id'], 'parent': plugin_data.get('plugin_parent', None), 'plugin_type': plugin_data['plugin_type'], 'position': plugin_data['plugin_position'], } if request.method == 'POST' and not plugin_class.show_add_form: # If the plugin has show_add_form set to False, # the post data is missing the initial values of the plugin form # Get the fields, the form and the initial values from the plugin instance # Replace the POST parameters by those initial values plus any concrete changes # the form. # TODO: Make this work with Text plugins which use ghost plugins and have to # have show_add_form=True fieldsets = plugin_instance.get_fieldsets(request, obj=None) fields = flatten_fieldsets(fieldsets) # Instantiate the add form for all fields initial_form = plugin_instance.get_form(request, None, change=False, fields=fields)() # Turn the initial values in a multi-value dict. In a multi-value dict each value is a list. # Hence, if the initial value is not a list, it is turned into a list. query_dict = MultiValueDict({ name: field.initial if isinstance(field.initial, (tuple, list)) else [field.initial] for name, field in initial_form.fields.items() if getattr(field, "initial", None) is not None and name not in request.POST }) # Add the actual post parameters query_dict.update(request.POST) # Use the QueryDict as the POST data request.POST = query_dict response = plugin_instance.add_view(request) plugin = getattr(plugin_instance, 'saved_object', None) if plugin_instance._operation_token: tree_order = plugin.placeholder.get_plugin_tree_order( plugin.language, plugin.parent_id, ) self._send_post_placeholder_operation( request, operation=operations.ADD_PLUGIN, token=plugin_instance._operation_token, plugin=plugin, placeholder=plugin.placeholder, tree_order=tree_order, ) return response @method_decorator(require_POST) @xframe_options_sameorigin @transaction.atomic def copy_plugins(self, request): """ POST request should have the following data: - cms_path - source_language - source_placeholder_id - source_plugin_id (optional) - target_language - target_placeholder_id """ source_placeholder_id = request.POST['source_placeholder_id'] target_language = request.POST['target_language'] target_placeholder_id = request.POST['target_placeholder_id'] source_placeholder = get_object_or_404(Placeholder, pk=source_placeholder_id) target_placeholder = get_object_or_404(Placeholder, pk=target_placeholder_id) if not target_language or target_language not in get_language_list(site_id=get_current_site(request).pk): return HttpResponseBadRequest(_("Language must be set to a supported language!")) clipboard = get_toolbar_from_request(request).clipboard copy_to_clipboard = clipboard is not None and target_placeholder.pk == clipboard.pk source_plugin_id = request.POST.get('source_plugin_id', None) if copy_to_clipboard and source_plugin_id: new_plugin = self._copy_plugin_to_clipboard( request, target_placeholder, ) new_plugins = [new_plugin] elif copy_to_clipboard: new_plugin = self._copy_placeholder_to_clipboard( request, source_placeholder, target_placeholder, ) new_plugins = [new_plugin] else: source_language = request.POST['source_language'] source_plugins = source_placeholder.get_plugins_list(language=source_language) disallowed = get_plugin_disallowed_in_slot( (plugin.plugin_type for plugin in source_plugins), target_placeholder.slot, ) if disallowed: return HttpResponseBadRequest( f"Plugin {disallowed} is not allowed in placeholder '{target_placeholder.slot}'." ) new_plugins = self._add_plugins_from_placeholder( request, source_placeholder, target_placeholder, ) data = get_plugin_tree(request, new_plugins) return HttpResponse(json.dumps(data), content_type='application/json') def _copy_plugin_to_clipboard(self, request, target_placeholder): source_language = request.POST['source_language'] source_plugin_id = request.POST.get('source_plugin_id') target_language = request.POST['target_language'] source_plugin = get_object_or_404( CMSPlugin, pk=source_plugin_id, language=source_language, ) old_plugins = [source_plugin] + list(source_plugin.get_descendants()) source_placeholder = source_plugin.placeholder if not self.has_copy_plugins_permission(request, old_plugins): message = _('You do not have permission to copy these plugins.') raise PermissionDenied(message) # Check source-side permission as well: copying reads the source # plugins, so a user without access to the source placeholder must not # be able to exfiltrate its content into their clipboard. if not source_placeholder.has_add_plugins_permission(request.user, old_plugins): message = _('You do not have permission to copy these plugins.') raise PermissionDenied(message) # Deliberately no ``source_placeholder.check_source()`` here: that is an # editability gate (see ``placeholder_is_immutable``), and copying only # reads the source. Editors must be able to copy out of content they may # not edit, such as a published version or a draft another user has # locked. Read access is covered by the permission check above. if not target_placeholder.check_source(request.user): message = _('You do not have permission to copy these plugins.') raise PermissionDenied(message) # Empty the clipboard target_placeholder.clear() copied_plugins = copy_plugins_to_placeholder( old_plugins, placeholder=target_placeholder, language=target_language, ) return copied_plugins[0] def _copy_placeholder_to_clipboard(self, request, source_placeholder, target_placeholder): source_language = request.POST['source_language'] target_language = request.POST['target_language'] # User is copying the whole placeholder to the clipboard. old_plugins = source_placeholder.get_plugins_list(language=source_language) if not self.has_copy_plugins_permission(request, old_plugins): message = _('You do not have permission to copy this placeholder.') raise PermissionDenied(message) # Check source-side permission as well: copying reads the source # plugins, so a user without access to the source placeholder must not # be able to exfiltrate its content into their clipboard. if not source_placeholder.has_add_plugins_permission(request.user, old_plugins): message = _('You do not have permission to copy this placeholder.') raise PermissionDenied(message) # Deliberately no ``source_placeholder.check_source()`` here: that is an # editability gate (see ``placeholder_is_immutable``), and copying only # reads the source. Editors must be able to copy out of content they may # not edit, such as a published version or a draft another user has # locked. Read access is covered by the permission check above. if not target_placeholder.check_source(request.user): message = _('You do not have permission to copy this placeholder.') raise PermissionDenied(message) # Empty the clipboard target_placeholder.clear() # Create a PlaceholderReference plugin which in turn # creates a blank placeholder called "clipboard" # the real clipboard has the reference placeholder inside but the plugins # are inside of the newly created blank clipboard. # This allows us to wrap all plugins in the clipboard under one plugin reference = PlaceholderReference.objects.create( name=source_placeholder.get_label(), plugin_type='PlaceholderPlugin', language=target_language, placeholder=target_placeholder, ) copy_plugins_to_placeholder( old_plugins, placeholder=reference.placeholder_ref, language=target_language, ) return reference def _add_plugins_from_placeholder(self, request, source_placeholder, target_placeholder): # Plugins are being copied from a placeholder in another language # using the "Copy from language" placeholder operation. source_language = request.POST['source_language'] target_language = request.POST['target_language'] old_plugins = source_placeholder.get_plugins_list(language=source_language) # Check if the user can copy plugins from source placeholder to # target placeholder. has_permissions = self.has_copy_from_placeholder_permission( request, source_placeholder, target_placeholder, old_plugins, ) if not has_permissions: message = _('You do not have permission to copy these plugins.') raise PermissionDenied(message) if not target_placeholder.check_source(request.user): message = _('You do not have permission to copy these plugins.') raise PermissionDenied(message) target_tree_order = target_placeholder.get_plugin_tree_order( language=target_language, parent_id=None, ) operation_token = self._send_pre_placeholder_operation( request, operation=operations.ADD_PLUGINS_FROM_PLACEHOLDER, plugins=old_plugins, source_language=source_language, source_placeholder=source_placeholder, target_language=target_language, target_placeholder=target_placeholder, target_order=target_tree_order, ) copied_plugins = copy_plugins_to_placeholder(old_plugins, target_placeholder, language=target_language) new_plugin_ids = (new.pk for new in copied_plugins) target_placeholder.clear_cache(target_language) new_plugins = CMSPlugin.objects.filter(pk__in=new_plugin_ids) new_plugins = list(new_plugins) target_tree_order = target_placeholder.get_plugin_tree_order( language=target_language, parent_id=None, ) self._send_post_placeholder_operation( request, operation=operations.ADD_PLUGINS_FROM_PLACEHOLDER, token=operation_token, plugins=new_plugins, source_language=source_language, source_placeholder=source_placeholder, target_language=target_language, target_placeholder=target_placeholder, target_order=target_tree_order, ) return new_plugins @xframe_options_sameorigin @transaction.atomic def edit_plugin(self, request, plugin_id): try: plugin_id = int(plugin_id) except ValueError: return HttpResponseNotFound(_("Plugin not found")) obj = self._get_plugin_from_id(plugin_id) # CMSPluginBase subclass instance plugin_instance = obj.get_plugin_class_instance(admin=self.admin_site) if not self.has_change_plugin_permission(request, obj): return HttpResponseForbidden(_("You do not have permission to edit this plugin")) if not obj.placeholder.check_source(request.user): message = _("You do not have permission to edit this plugin") return HttpResponseForbidden(message) response = plugin_instance.change_view(request, str(plugin_id)) plugin = getattr(plugin_instance, 'saved_object', None) if plugin: plugin.placeholder.clear_cache(plugin.language) if plugin_instance._operation_token: self._send_post_placeholder_operation( request, operation=operations.CHANGE_PLUGIN, token=plugin_instance._operation_token, old_plugin=obj, new_plugin=plugin, placeholder=plugin.placeholder, ) return response @method_decorator(require_POST) @xframe_options_sameorigin @transaction.atomic def move_plugin(self, request): """ Performs a move or a "paste" operation (when «move_a_copy» is set) POST request with following parameters: - plugin_id - placeholder_id - target_position (optional) - plugin_language (optional) - plugin_parent (optional) - plugin_order (array, optional) - move_a_copy (Boolean, optional) (anything supplied here except a case- insensitive "false" is True) NOTE: If move_a_copy is set, the plugin_order should contain an item '__COPY__' with the desired destination of the copied plugin. """ # plugin_id and placeholder_id are required, so, if nothing is supplied, # an ValueError exception will be raised by get_int(). try: plugin_id = get_int(request.POST.get('plugin_id')) except TypeError: raise RuntimeError("'plugin_id' is a required parameter.") plugin = self._get_plugin_from_id(plugin_id) if request.POST.get('placeholder_id'): placeholder_id = get_int(request.POST.get('placeholder_id')) placeholder = Placeholder.objects.get(pk=placeholder_id) assert plugin.placeholder_id != placeholder_id else: placeholder = None # The rest are optional target_position = int(request.POST.get('target_position', "0")) parent_id = get_int(request.POST.get('plugin_parent', ""), None) target_language = request.POST['target_language'] move_a_copy = request.POST.get('move_a_copy') move_a_copy = ( move_a_copy and move_a_copy != "0" and move_a_copy.lower() != "false" ) clipboard = get_toolbar_from_request(request).clipboard # Both may be None, which must not be read as "moving to the clipboard". move_to_clipboard = clipboard is not None and placeholder == clipboard source_placeholder = plugin.placeholder if placeholder and placeholder != source_placeholder: if not move_to_clipboard: # The whole subtree moves with the plugin, so every descendant must # also be allowed in the target slot, not just the moved plugin itself. moved_types = [plugin.plugin_type, *plugin.get_descendants().values_list("plugin_type", flat=True)] disallowed = get_plugin_disallowed_in_slot(moved_types, placeholder.slot) if disallowed: return HttpResponseBadRequest( f"Plugin {disallowed} is not allowed in placeholder '{placeholder.slot}'." ) try: template = self.get_placeholder_template(request, placeholder) has_reached_plugin_limit(placeholder, plugin.plugin_type, target_language, template=template) except PluginLimitReached as er: return HttpResponseBadRequest(er) if parent_id and plugin.parent_id != parent_id: target_pl = placeholder or plugin.placeholder # The new parent must live in the placeholder (and language) the # plugin ends up in. This holds for pastes and cuts just as much as # for moves: ``plugin_parent`` is client supplied, so an unscoped # lookup would let a user name a plugin in a placeholder they have # no permission for. That both parents the new plugin outside its # own placeholder and returns the foreign parent's rendered subtree # in the response. target_parent = get_object_or_404( CMSPlugin, pk=parent_id, language=target_language, placeholder=target_pl, ) elif parent_id and placeholder is None: # ``plugin_parent`` names the plugin's current parent. Keeping it # only makes sense while the plugin stays in its placeholder; the # parent does not travel to another one. It is still looked up # within the plugin's own placeholder and language: a relation # pointing elsewhere (e.g., created before the lookup above was # scoped) must not be trusted, or its subtree ends up in the # response. target_parent = get_object_or_404( CMSPlugin, pk=parent_id, language=plugin.language, placeholder=plugin.placeholder_id, ) else: target_parent = None # Reparenting an existing plugin under itself or one of its own # descendants would create a cycle in the plugin tree. The recursive # descendant/ancestor queries would then loop indefinitely, stalling # request handling (denial of service). Reject such moves. # (Copies and cut-to-clipboard create new plugins, so they are safe.) if ( target_parent and not move_a_copy and not move_to_clipboard and ( target_parent.pk == plugin.pk or target_parent.pk in plugin._get_descendants_ids() ) ): return HttpResponseBadRequest( "A plugin cannot be moved inside itself or one of its descendants." ) old_parent = None fetch_tree = True if move_a_copy and plugin.plugin_type == "PlaceholderPlugin": fetch_tree = False new_plugin = None new_plugins = self._paste_placeholder( request, plugin=plugin, target_language=target_language, target_placeholder=placeholder, target_position=target_position, ) elif move_a_copy: new_plugin = self._paste_plugin( request, plugin=plugin, target_parent=target_parent, target_language=target_language, target_placeholder=placeholder, target_position=target_position, ) elif move_to_clipboard: old_parent = plugin.parent # Previous parent needs content update, too new_plugin = self._cut_plugin( request, plugin=plugin, target_language=target_language, target_placeholder=placeholder, ) new_plugins = [plugin] else: old_parent = plugin.parent # Previous parent needs content update, too new_plugin = self._move_plugin( request, plugin=plugin, target_parent=target_parent, target_position=target_position, target_placeholder=placeholder, ) if new_plugin and fetch_tree: root = (new_plugin.parent or new_plugin) new_plugins = [root] + list(root.get_descendants()) data = get_plugin_tree(request, new_plugins, target_plugin=new_plugins[0]) if old_parent and old_parent not in new_plugins and "content" in data: # Update previous parent and its children old_parent_plugins = list(downcast_plugins([old_parent] + list(old_parent.get_descendants()), select_placeholder=True)) create_child_plugin_references(old_parent_plugins) data["content"] += get_plugin_content(request, old_parent_plugins[0]) # Pass the target_position if "content" in data and len(data["content"]) > 0: data["content"][0]["insert"] = new_plugins[0].pk == plugin.pk # Insert the content (old_parent is always only updated) if move_to_clipboard: data["content"].pop(0) data["source_placeholder_id"] = source_placeholder.pk return HttpResponse(json.dumps(data), content_type='application/json') def _paste_plugin(self, request, plugin, target_language, target_placeholder, target_position, target_parent=None): plugins = [plugin] + list(plugin.get_descendants()) # Check the source side as well: the plugin is identified by a # client-supplied id, so it is not necessarily one the user is allowed # to read. if not self.has_paste_from_source_permission(request, plugin.placeholder, plugins): message = _("You have no permission to paste this plugin") raise PermissionDenied(message) if not self.has_copy_from_clipboard_permission(request, target_placeholder, plugins): message = _("You have no permission to paste this plugin") raise PermissionDenied(message) if not target_placeholder.check_source(request.user): message = _("You have no permission to paste this plugin") raise PermissionDenied(message) if target_parent: target_parent_id = target_parent.pk else: target_parent_id = None action_token = self._send_pre_placeholder_operation( request, operation=operations.PASTE_PLUGIN, plugin=plugin, target_language=target_language, target_placeholder=target_placeholder, target_parent_id=target_parent_id, ) target_last_plugin = target_placeholder.get_last_plugin(target_language) if target_last_plugin: target_offset = target_last_plugin.position + len(plugins) target_placeholder._shift_plugin_positions( target_language, start=target_position, offset=target_offset, ) new_plugins = copy_plugins_to_placeholder( plugins, placeholder=target_placeholder, language=target_language, root_plugin=target_parent, start_positions={target_language: target_position}, ) root_plugin = new_plugins[0] target_placeholder.clear_cache(target_language) # Fetch from db to update position and other tree values root_plugin.refresh_from_db() self._send_post_placeholder_operation( request, operation=operations.PASTE_PLUGIN, plugin=root_plugin.get_bound_plugin(), token=action_token, target_language=target_language, target_placeholder=target_placeholder, target_parent_id=target_parent_id, ) return root_plugin def _paste_placeholder(self, request, plugin, target_language, target_placeholder, target_position): plugins = plugin.placeholder_ref.get_plugins_list() # Check the source side as well: the plugin is identified by a # client-supplied id, so it is not necessarily one the user is allowed # to read. The plugins live in the reference placeholder, but that one # is only reachable through the placeholder holding the reference # plugin itself (normally the user's own clipboard). if not self.has_paste_from_source_permission(request, plugin.placeholder, plugins): message = _("You have no permission to paste this placeholder") raise PermissionDenied(message) if not self.has_copy_from_clipboard_permission(request, target_placeholder, plugins): message = _("You have no permission to paste this placeholder") raise PermissionDenied(message) if not target_placeholder.check_source(request.user): message = _("You have no permission to paste this placeholder") raise PermissionDenied(message) action_token = self._send_pre_placeholder_operation( request, operation=operations.PASTE_PLACEHOLDER, plugins=plugins, target_language=target_language, target_placeholder=target_placeholder, ) target_last_plugin = target_placeholder.get_last_plugin(target_language) if target_last_plugin: target_offset = target_last_plugin.position + len(plugins) target_placeholder._shift_plugin_positions( target_language, start=target_position, offset=target_offset, ) new_plugins = copy_plugins_to_placeholder( plugins, placeholder=target_placeholder, language=target_language, start_positions={target_language: target_position}, ) new_plugin_ids = (new.pk for new in new_plugins) target_placeholder.clear_cache(target_language) new_plugins = ( CMSPlugin .objects .filter(pk__in=new_plugin_ids) .select_related('placeholder') ) new_plugins = list(new_plugins) self._send_post_placeholder_operation( request, operation=operations.PASTE_PLACEHOLDER, token=action_token, plugins=new_plugins, target_language=target_language, target_placeholder=target_placeholder, ) return new_plugins def _move_plugin(self, request, plugin, target_position, target_placeholder=None, target_parent=None): language = plugin.language source_placeholder = plugin.placeholder source_language = plugin.language source_parent_id = plugin.parent_id if not self.has_move_plugin_permission(request, plugin, source_placeholder): message = _("You have no permission to move this plugin") raise PermissionDenied(message) if target_placeholder and not self.has_move_plugin_permission(request, plugin, target_placeholder): message = _("You have no permission to move this plugin") raise PermissionDenied(message) if not source_placeholder.check_source(request.user): message = _("You have no permission to move this plugin") raise PermissionDenied(message) if target_placeholder and not target_placeholder.check_source(request.user): message = _("You have no permission to move this plugin") raise PermissionDenied(message) if target_parent: target_parent_id = target_parent.pk else: target_parent_id = None action_token = self._send_pre_placeholder_operation( request, operation=operations.MOVE_PLUGIN, plugin=plugin, source_language=source_language, source_placeholder=source_placeholder, source_parent_id=source_parent_id, target_language=language, target_placeholder=(target_placeholder or source_placeholder), target_parent_id=target_parent_id, ) source_placeholder.move_plugin( plugin=plugin, target_position=target_position, target_placeholder=target_placeholder, target_plugin=target_parent, ) # Refresh plugin to get new position values updated_plugin = plugin.reload() if target_placeholder: target_placeholder.clear_cache(language) source_placeholder.clear_cache(language) self._send_post_placeholder_operation( request, operation=operations.MOVE_PLUGIN, plugin=updated_plugin.get_bound_plugin(), token=action_token, source_language=source_language, source_placeholder=source_placeholder, source_parent_id=source_parent_id, target_language=language, target_placeholder=(target_placeholder or source_placeholder), target_parent_id=target_parent_id, ) return updated_plugin def _cut_plugin(self, request, plugin, target_language, target_placeholder): source_placeholder = plugin.placeholder source_language = plugin.language source_parent_id = plugin.parent_id if not self.has_move_plugin_permission(request, plugin, source_placeholder): message = _("You have no permission to cut this plugin") raise PermissionDenied(message) if not source_placeholder.check_source(request.user): message = _("You have no permission to cut this plugin") raise PermissionDenied(message) source_order = source_placeholder.get_plugin_tree_order( source_language, source_parent_id, ) action_token = self._send_pre_placeholder_operation( request, operation=operations.CUT_PLUGIN, plugin=plugin, clipboard=target_placeholder, clipboard_language=target_language, source_language=source_language, source_placeholder=source_placeholder, source_parent_id=source_parent_id, source_order=source_order, ) # Empty the clipboard target_placeholder.clear() source_placeholder.move_plugin( plugin, target_position=1, target_placeholder=target_placeholder, target_plugin=None, ) source_placeholder.clear_cache(plugin.language) updated_plugin = plugin.reload() source_order = source_placeholder.get_plugin_tree_order( source_language, source_parent_id, ) self._send_post_placeholder_operation( request, operation=operations.CUT_PLUGIN, token=action_token, plugin=updated_plugin.get_bound_plugin(), clipboard=target_placeholder, clipboard_language=target_language, source_language=source_language, source_placeholder=source_placeholder, source_parent_id=source_parent_id, source_order=source_order, ) return updated_plugin @xframe_options_sameorigin def delete_plugin(self, request, plugin_id): plugin = self._get_plugin_from_id(plugin_id) if not self.has_delete_plugin_permission(request, plugin): message = _("You do not have permission to delete this plugin") return HttpResponseForbidden(message) if not plugin.placeholder.check_source(request.user): message = _("You do not have permission to delete this plugin") raise PermissionDenied(message) opts = plugin._meta get_deleted_objects_additional_kwargs = {'request': request} deleted_objects, __, perms_needed, protected = get_deleted_objects( [plugin], admin_site=self.admin_site, **get_deleted_objects_additional_kwargs ) if request.POST: # The user has already confirmed the deletion. if perms_needed: raise PermissionDenied(_("You do not have permission to delete this plugin")) obj_display = force_str(plugin) placeholder = plugin.placeholder plugin_class_instance = plugin.get_plugin_class_instance() plugin_parent = plugin.parent plugin_tree_order = placeholder.get_plugin_tree_order(language=plugin.language) operation_token = self._send_pre_placeholder_operation( request, operation=operations.DELETE_PLUGIN, plugin=plugin, placeholder=placeholder, tree_order=plugin_tree_order, ) placeholder.delete_plugin(plugin) placeholder.clear_cache(plugin.language) plugin_tree_order = placeholder.get_plugin_tree_order(language=plugin.language) self.message_user(request, _('The %(name)s plugin "%(obj)s" was deleted successfully.') % { 'name': force_str(opts.verbose_name), 'obj': force_str(obj_display)}) self._send_post_placeholder_operation( request, operation=operations.DELETE_PLUGIN, token=operation_token, plugin=plugin, placeholder=placeholder, tree_order=plugin_tree_order, ) return plugin_class_instance.render_close_frame( request, plugin_parent, action="delete", extra_data=dict(deleted=True, plugin_id=plugin.pk)) plugin_name = force_str(plugin.get_plugin_class().name) if perms_needed or protected: title = _("Cannot delete %(name)s") % {"name": plugin_name} else: title = _("Are you sure?") context = { "title": title, "object_name": plugin_name, "object": plugin, "deleted_objects": deleted_objects, "perms_lacking": perms_needed, "protected": protected, "opts": opts, "app_label": opts.app_label, "delete_confirmation_max_display": getattr(self, "delete_confirmation_max_display", None), # This view is only ever shown inside the CMS modal: render it without the # admin chrome, like the other frontend editing views do. "is_popup": True, "is_popup_var": IS_POPUP_VAR, } request.current_app = self.admin_site.name return TemplateResponse( request, "admin/cms/page/plugin/delete_confirmation.html", context ) @xframe_options_sameorigin def clear_placeholder(self, request, placeholder_id): placeholder = get_object_or_404(Placeholder, pk=placeholder_id) language = request.GET.get('language') clipboard = get_toolbar_from_request(request).clipboard if clipboard is not None and placeholder.pk == clipboard.pk: # User is clearing the clipboard, no need for permission # checks here as the clipboard is unique per user. # There could be a case where a plugin has relationship to # an object the user does not have permission to delete. # The clipboard is cleared immediately (the other branch # renders a confirmation page first), so require POST to keep # this state-changing operation off CSRF-exempt GET requests. if request.method != "POST": return HttpResponseNotAllowed(["POST"]) placeholder.clear(language) return TemplateResponse(request, "admin/cms/page/plugin/confirm_form.html", { "data_bridge": { "action": "clear_placeholder", "deleted": True, "placeholder_id": placeholder.pk, } }) if not self.has_clear_placeholder_permission(request, placeholder, language): message = _("You do not have permission to clear this placeholder") return HttpResponseForbidden(message) if not placeholder.check_source(request.user): message = _("You do not have permission to clear this placeholder") raise PermissionDenied(message) opts = Placeholder._meta plugins = placeholder.get_plugins_list(language) get_deleted_objects_additional_kwargs = {'request': request} deleted_objects, __, perms_needed, protected = get_deleted_objects( plugins, admin_site=self.admin_site, **get_deleted_objects_additional_kwargs ) obj_display = force_str(placeholder) if request.POST: # The user has already confirmed the deletion. if perms_needed: message = _("You do not have permission to clear this placeholder") return HttpResponseForbidden(message) operation_token = self._send_pre_placeholder_operation( request, operation=operations.CLEAR_PLACEHOLDER, plugins=plugins, placeholder=placeholder, ) placeholder.clear(language) placeholder.clear_cache(language, site_id=get_current_site(request).pk) self.message_user(request, _('The placeholder "%(obj)s" was cleared successfully.') % { 'obj': obj_display}) self._send_post_placeholder_operation( request, operation=operations.CLEAR_PLACEHOLDER, token=operation_token, plugins=plugins, placeholder=placeholder, ) return TemplateResponse(request, "admin/cms/page/plugin/confirm_form.html", { "data_bridge": { "action": "clear_placeholder", "deleted": True, "placeholder_id": placeholder.pk, } }) if perms_needed or protected: title = _("Cannot delete %(name)s") % {"name": obj_display} else: title = _("Are you sure?") context = { "title": title, "object_name": _("placeholder"), "object": placeholder, "deleted_objects": deleted_objects, "perms_lacking": perms_needed, "protected": protected, "opts": opts, "app_label": opts.app_label, "delete_confirmation_max_display": getattr(self, "delete_confirmation_max_display", None), # This view is only ever shown inside the CMS modal: render it without the # admin chrome, like the other frontend editing views do. "is_popup": True, "is_popup_var": IS_POPUP_VAR, } request.current_app = self.admin_site.name return TemplateResponse(request, "admin/cms/page/plugin/delete_confirmation.html", context) class PlaceholderAdminMixin: """ Compatibility shim for older third-party packages such as aldryn-newsblog. """ pass