dynamicweb/cms/admin/placeholderadmin.py
2026-10-01 11:51:55 +01:00

1363 lines
No EOL
57 KiB
Python

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