1# -*- coding: utf-8 -*-
2import uuid
3import warnings
4
5from django.conf.urls import url
6from django.contrib.admin.helpers import AdminForm
7from django.contrib.admin.utils import get_deleted_objects
8from django.core.exceptions import PermissionDenied
9from django.db import router, transaction
10from django.http import (
11    HttpResponse,
12    HttpResponseBadRequest,
13    HttpResponseForbidden,
14    HttpResponseNotFound,
15    HttpResponseRedirect,
16)
17from django.shortcuts import get_list_or_404, get_object_or_404, render
18from django.template.response import TemplateResponse
19from django.utils import six
20from django.utils.six.moves.urllib.parse import parse_qsl, urlparse
21from django.utils.decorators import method_decorator
22from django.utils.encoding import force_text
23from django.utils import translation
24from django.utils.translation import ugettext as _
25from django.views.decorators.clickjacking import xframe_options_sameorigin
26from django.views.decorators.http import require_POST
27
28from cms import operations
29from cms.admin.forms import PluginAddValidationForm
30from cms.constants import SLUG_REGEXP
31from cms.exceptions import PluginLimitReached
32from cms.models.placeholdermodel import Placeholder
33from cms.models.placeholderpluginmodel import PlaceholderReference
34from cms.models.pluginmodel import CMSPlugin
35from cms.plugin_pool import plugin_pool
36from cms.signals import pre_placeholder_operation, post_placeholder_operation
37from cms.toolbar.utils import get_plugin_tree_as_json
38from cms.utils import copy_plugins, get_current_site
39from cms.utils.compat import DJANGO_2_0
40from cms.utils.conf import get_cms_setting
41from cms.utils.i18n import get_language_code, get_language_list
42from cms.utils.plugins import has_reached_plugin_limit, reorder_plugins
43from cms.utils.urlutils import admin_reverse
44
45_no_default = object()
46
47
48def get_int(int_str, default=_no_default):
49    """
50    For convenience a get-like method for taking the int() of a string.
51    :param int_str: the string to convert to integer
52    :param default: an optional value to return if ValueError is raised.
53    :return: the int() of «int_str» or «default» on exception.
54    """
55    if default == _no_default:
56        return int(int_str)
57    else:
58        try:
59            return int(int_str)
60        except ValueError:
61            return default
62
63
64def _instance_overrides_method(base, instance, method_name):
65    """
66    Returns True if instance overrides a method (method_name)
67    inherited from base.
68    """
69    bound_method = getattr(instance, method_name)
70    unbound_method = getattr(base, method_name)
71    return six.get_unbound_function(unbound_method) != six.get_method_function(bound_method)
72
73
74class FrontendEditableAdminMixin(object):
75    frontend_editable_fields = []
76
77    def get_urls(self):
78        """
79        Register the url for the single field edit view
80        """
81        info = "%s_%s" % (self.model._meta.app_label, self.model._meta.model_name)
82        pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
83        url_patterns = [
84            pat(r'edit-field/(%s)/([a-z\-]+)/$' % SLUG_REGEXP, self.edit_field),
85        ]
86        return url_patterns + super(FrontendEditableAdminMixin, self).get_urls()
87
88    def _get_object_for_single_field(self, object_id, language):
89        # Quick and dirty way to retrieve objects for django-hvad
90        # Cleaner implementation will extend this method in a child mixin
91        try:
92            return self.model.objects.language(language).get(pk=object_id)
93        except AttributeError:
94            return self.model.objects.get(pk=object_id)
95
96    def edit_field(self, request, object_id, language):
97        obj = self._get_object_for_single_field(object_id, language)
98        opts = obj.__class__._meta
99        saved_successfully = False
100        cancel_clicked = request.POST.get("_cancel", False)
101        raw_fields = request.GET.get("edit_fields")
102        fields = [field for field in raw_fields.split(",") if field in self.frontend_editable_fields]
103        if not fields:
104            context = {
105                'opts': opts,
106                'message': force_text(_("Field %s not found")) % raw_fields
107            }
108            return render(request, 'admin/cms/page/plugin/error_form.html', context)
109        if not request.user.has_perm("{0}.change_{1}".format(self.model._meta.app_label,
110                                                             self.model._meta.model_name)):
111            context = {
112                'opts': opts,
113                'message': force_text(_("You do not have permission to edit this item"))
114            }
115            return render(request, 'admin/cms/page/plugin/error_form.html', context)
116            # Dynamically creates the form class with only `field_name` field
117        # enabled
118        form_class = self.get_form(request, obj, fields=fields)
119        if not cancel_clicked and request.method == 'POST':
120            form = form_class(instance=obj, data=request.POST)
121            if form.is_valid():
122                form.save()
123                saved_successfully = True
124        else:
125            form = form_class(instance=obj)
126        admin_form = AdminForm(form, fieldsets=[(None, {'fields': fields})], prepopulated_fields={},
127                               model_admin=self)
128        media = self.media + admin_form.media
129        context = {
130            'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'),
131            'title': opts.verbose_name,
132            'plugin': None,
133            'plugin_id': None,
134            'adminform': admin_form,
135            'add': False,
136            'is_popup': True,
137            'media': media,
138            'opts': opts,
139            'change': True,
140            'save_as': False,
141            'has_add_permission': False,
142            'window_close_timeout': 10,
143        }
144        if cancel_clicked:
145            # cancel button was clicked
146            context.update({
147                'cancel': True,
148            })
149            return render(request, 'admin/cms/page/plugin/confirm_form.html', context)
150        if not cancel_clicked and request.method == 'POST' and saved_successfully:
151            return render(request, 'admin/cms/page/plugin/confirm_form.html', context)
152        return render(request, 'admin/cms/page/plugin/change_form.html', context)
153
154
155class PlaceholderAdminMixin(object):
156
157    def _get_attached_admin(self, placeholder):
158        return placeholder._get_attached_admin(admin_site=self.admin_site)
159
160    def _get_operation_language(self, request):
161        # Unfortunately the ?language GET query
162        # has a special meaning on the CMS.
163        # It allows users to see another language while maintaining
164        # the same url. This complicates language detection.
165        site = get_current_site()
166        parsed_url = urlparse(request.GET['cms_path'])
167        queries = dict(parse_qsl(parsed_url.query))
168        language = queries.get('language')
169
170        if not language:
171            language = translation.get_language_from_path(parsed_url.path)
172        return get_language_code(language, site_id=site.pk)
173
174    def _get_operation_origin(self, request):
175        return urlparse(request.GET['cms_path']).path
176
177    def _send_pre_placeholder_operation(self, request, operation, **kwargs):
178        token = str(uuid.uuid4())
179
180        if not request.GET.get('cms_path'):
181            warnings.warn('All custom placeholder admin endpoints require '
182                          'a "cms_path" GET query which points to the path '
183                          'where the request originates from.'
184                          'This backwards compatible shim will be removed on 3.5 '
185                          'and an HttpBadRequest response will be returned instead.',
186                          UserWarning)
187            return token
188
189        pre_placeholder_operation.send(
190            sender=self.__class__,
191            operation=operation,
192            request=request,
193            language=self._get_operation_language(request),
194            token=token,
195            origin=self._get_operation_origin(request),
196            **kwargs
197        )
198        return token
199
200    def _send_post_placeholder_operation(self, request, operation, token, **kwargs):
201        if not request.GET.get('cms_path'):
202            # No need to re-raise the warning
203            return
204
205        post_placeholder_operation.send(
206            sender=self.__class__,
207            operation=operation,
208            request=request,
209            language=self._get_operation_language(request),
210            token=token,
211            origin=self._get_operation_origin(request),
212            **kwargs
213        )
214
215    def _get_plugin_from_id(self, plugin_id):
216        queryset = CMSPlugin.objects.values_list('plugin_type', flat=True)
217        plugin_type = get_list_or_404(queryset, pk=plugin_id)[0]
218        # CMSPluginBase subclass
219        plugin_class = plugin_pool.get_plugin(plugin_type)
220        real_queryset = plugin_class.get_render_queryset().select_related('parent', 'placeholder')
221        return get_object_or_404(real_queryset, pk=plugin_id)
222
223    def get_urls(self):
224        """
225        Register the plugin specific urls (add/edit/copy/remove/move)
226        """
227        info = "%s_%s" % (self.model._meta.app_label, self.model._meta.model_name)
228        pat = lambda regex, fn: url(regex, self.admin_site.admin_view(fn), name='%s_%s' % (info, fn.__name__))
229        url_patterns = [
230            pat(r'copy-plugins/$', self.copy_plugins),
231            pat(r'add-plugin/$', self.add_plugin),
232            pat(r'edit-plugin/(%s)/$' % SLUG_REGEXP, self.edit_plugin),
233            pat(r'delete-plugin/(%s)/$' % SLUG_REGEXP, self.delete_plugin),
234            pat(r'clear-placeholder/(%s)/$' % SLUG_REGEXP, self.clear_placeholder),
235            pat(r'move-plugin/$', self.move_plugin),
236        ]
237        return url_patterns + super(PlaceholderAdminMixin, self).get_urls()
238
239    def has_add_plugin_permission(self, request, placeholder, plugin_type):
240        return placeholder.has_add_plugin_permission(request.user, plugin_type)
241
242    def has_change_plugin_permission(self, request, plugin):
243        placeholder = plugin.placeholder
244        return placeholder.has_change_plugin_permission(request.user, plugin)
245
246    def has_delete_plugin_permission(self, request, plugin):
247        placeholder = plugin.placeholder
248        return placeholder.has_delete_plugin_permission(request.user, plugin)
249
250    def has_copy_plugins_permission(self, request, plugins):
251        # Plugins can only be copied to the clipboard
252        placeholder = request.toolbar.clipboard
253        return placeholder.has_add_plugins_permission(request.user, plugins)
254
255    def has_copy_from_clipboard_permission(self, request, placeholder, plugins):
256        return placeholder.has_add_plugins_permission(request.user, plugins)
257
258    def has_copy_from_placeholder_permission(self, request, source_placeholder, target_placeholder, plugins):
259        if not source_placeholder.has_add_plugins_permission(request.user, plugins):
260            return False
261        return target_placeholder.has_add_plugins_permission(request.user, plugins)
262
263    def has_move_plugin_permission(self, request, plugin, target_placeholder):
264        placeholder = plugin.placeholder
265        return placeholder.has_move_plugin_permission(request.user, plugin, target_placeholder)
266
267    def has_clear_placeholder_permission(self, request, placeholder, language=None):
268        if language:
269            languages = [language]
270        else:
271            # fetch all languages this placeholder contains
272            # based on it's plugins
273            languages = (
274                placeholder
275                .cmsplugin_set
276                .values_list('language', flat=True)
277                .distinct()
278                .order_by()
279            )
280        return placeholder.has_clear_permission(request.user, languages)
281
282    def get_placeholder_template(self, request, placeholder):
283        pass
284
285    @xframe_options_sameorigin
286    def add_plugin(self, request):
287        """
288        Shows the add plugin form and saves it on POST.
289
290        Requires the following GET parameters:
291            - cms_path
292            - placeholder_id
293            - plugin_type
294            - plugin_language
295            - plugin_parent (optional)
296            - plugin_position (optional)
297        """
298        form = PluginAddValidationForm(request.GET)
299
300        if not form.is_valid():
301            # list() is necessary for python 3 compatibility.
302            # errors is s dict mapping fields to a list of errors
303            # for that field.
304            error = list(form.errors.values())[0][0]
305            return HttpResponseBadRequest(force_text(error))
306
307        plugin_data = form.cleaned_data
308        placeholder = plugin_data['placeholder_id']
309        plugin_type = plugin_data['plugin_type']
310
311        if not self.has_add_plugin_permission(request, placeholder, plugin_type):
312            message = force_text(_('You do not have permission to add a plugin'))
313            return HttpResponseForbidden(message)
314
315        parent = plugin_data.get('plugin_parent')
316
317        if parent:
318            position = parent.cmsplugin_set.count()
319        else:
320            position = CMSPlugin.objects.filter(
321                parent__isnull=True,
322                language=plugin_data['plugin_language'],
323                placeholder=placeholder,
324            ).count()
325
326        plugin_data['position'] = position
327
328        plugin_class = plugin_pool.get_plugin(plugin_type)
329        plugin_instance = plugin_class(plugin_class.model, self.admin_site)
330
331        # Setting attributes on the form class is perfectly fine.
332        # The form class is created by modelform factory every time
333        # this get_form() method is called.
334        plugin_instance._cms_initial_attributes = {
335            'language': plugin_data['plugin_language'],
336            'placeholder': plugin_data['placeholder_id'],
337            'parent': plugin_data.get('plugin_parent', None),
338            'plugin_type': plugin_data['plugin_type'],
339            'position': plugin_data['position'],
340        }
341
342        response = plugin_instance.add_view(request)
343
344        plugin = getattr(plugin_instance, 'saved_object', None)
345
346        if plugin:
347            plugin.placeholder.mark_as_dirty(plugin.language, clear_cache=False)
348
349        if plugin_instance._operation_token:
350            tree_order = placeholder.get_plugin_tree_order(plugin.parent_id)
351            self._send_post_placeholder_operation(
352                request,
353                operation=operations.ADD_PLUGIN,
354                token=plugin_instance._operation_token,
355                plugin=plugin,
356                placeholder=plugin.placeholder,
357                tree_order=tree_order,
358            )
359        return response
360
361    @method_decorator(require_POST)
362    @xframe_options_sameorigin
363    @transaction.atomic
364    def copy_plugins(self, request):
365        """
366        POST request should have the following data:
367
368        - cms_path
369        - source_language
370        - source_placeholder_id
371        - source_plugin_id (optional)
372        - target_language
373        - target_placeholder_id
374        - target_plugin_id (deprecated/unused)
375        """
376        source_placeholder_id = request.POST['source_placeholder_id']
377        target_language = request.POST['target_language']
378        target_placeholder_id = request.POST['target_placeholder_id']
379        source_placeholder = get_object_or_404(Placeholder, pk=source_placeholder_id)
380        target_placeholder = get_object_or_404(Placeholder, pk=target_placeholder_id)
381
382        if not target_language or not target_language in get_language_list():
383            return HttpResponseBadRequest(force_text(_("Language must be set to a supported language!")))
384
385        copy_to_clipboard = target_placeholder.pk == request.toolbar.clipboard.pk
386        source_plugin_id = request.POST.get('source_plugin_id', None)
387
388        if copy_to_clipboard and source_plugin_id:
389            new_plugin = self._copy_plugin_to_clipboard(
390                request,
391                source_placeholder,
392                target_placeholder,
393            )
394            new_plugins = [new_plugin]
395        elif copy_to_clipboard:
396            new_plugin = self._copy_placeholder_to_clipboard(
397                request,
398                source_placeholder,
399                target_placeholder,
400            )
401            new_plugins = [new_plugin]
402        else:
403            new_plugins = self._add_plugins_from_placeholder(
404                request,
405                source_placeholder,
406                target_placeholder,
407            )
408        data = get_plugin_tree_as_json(request, new_plugins)
409        return HttpResponse(data, content_type='application/json')
410
411    def _copy_plugin_to_clipboard(self, request, source_placeholder, target_placeholder):
412        source_language = request.POST['source_language']
413        source_plugin_id = request.POST.get('source_plugin_id')
414        target_language = request.POST['target_language']
415
416        source_plugin = get_object_or_404(
417            CMSPlugin,
418            pk=source_plugin_id,
419            language=source_language,
420        )
421
422        old_plugins = (
423            CMSPlugin
424            .get_tree(parent=source_plugin)
425            .filter(placeholder=source_placeholder)
426            .order_by('path')
427        )
428
429        if not self.has_copy_plugins_permission(request, old_plugins):
430            message = _('You do not have permission to copy these plugins.')
431            raise PermissionDenied(force_text(message))
432
433        # Empty the clipboard
434        target_placeholder.clear()
435
436        plugin_pairs = copy_plugins.copy_plugins_to(
437            old_plugins,
438            to_placeholder=target_placeholder,
439            to_language=target_language,
440        )
441        return plugin_pairs[0][0]
442
443    def _copy_placeholder_to_clipboard(self, request, source_placeholder, target_placeholder):
444        source_language = request.POST['source_language']
445        target_language = request.POST['target_language']
446
447        # User is copying the whole placeholder to the clipboard.
448        old_plugins = source_placeholder.get_plugins_list(language=source_language)
449
450        if not self.has_copy_plugins_permission(request, old_plugins):
451            message = _('You do not have permission to copy this placeholder.')
452            raise PermissionDenied(force_text(message))
453
454        # Empty the clipboard
455        target_placeholder.clear()
456
457        # Create a PlaceholderReference plugin which in turn
458        # creates a blank placeholder called "clipboard"
459        # the real clipboard has the reference placeholder inside but the plugins
460        # are inside of the newly created blank clipboard.
461        # This allows us to wrap all plugins in the clipboard under one plugin
462        reference = PlaceholderReference.objects.create(
463            name=source_placeholder.get_label(),
464            plugin_type='PlaceholderPlugin',
465            language=target_language,
466            placeholder=target_placeholder,
467        )
468
469        copy_plugins.copy_plugins_to(
470            old_plugins,
471            to_placeholder=reference.placeholder_ref,
472            to_language=target_language,
473        )
474        return reference
475
476    def _add_plugins_from_placeholder(self, request, source_placeholder, target_placeholder):
477        # Plugins are being copied from a placeholder in another language
478        # using the "Copy from language" placeholder operation.
479        source_language = request.POST['source_language']
480        target_language = request.POST['target_language']
481
482        old_plugins = source_placeholder.get_plugins_list(language=source_language)
483
484        # Check if the user can copy plugins from source placeholder to
485        # target placeholder.
486        has_permissions = self.has_copy_from_placeholder_permission(
487            request,
488            source_placeholder,
489            target_placeholder,
490            old_plugins,
491        )
492
493        if not has_permissions:
494            message = _('You do not have permission to copy these plugins.')
495            raise PermissionDenied(force_text(message))
496
497        target_tree_order = target_placeholder.get_plugin_tree_order(
498            language=target_language,
499            parent_id=None,
500        )
501
502        operation_token = self._send_pre_placeholder_operation(
503            request,
504            operation=operations.ADD_PLUGINS_FROM_PLACEHOLDER,
505            plugins=old_plugins,
506            source_language=source_language,
507            source_placeholder=source_placeholder,
508            target_language=target_language,
509            target_placeholder=target_placeholder,
510            target_order=target_tree_order,
511        )
512
513        copied_plugins = copy_plugins.copy_plugins_to(
514            old_plugins,
515            to_placeholder=target_placeholder,
516            to_language=target_language,
517        )
518
519        new_plugin_ids = (new.pk for new, old in copied_plugins)
520
521        # Creates a list of PKs for the top-level plugins ordered by
522        # their position.
523        top_plugins = (pair for pair in copied_plugins if not pair[0].parent_id)
524        top_plugins_pks = [p[0].pk for p in sorted(top_plugins, key=lambda pair: pair[1].position)]
525
526        # All new plugins are added to the bottom
527        target_tree_order = target_tree_order + top_plugins_pks
528
529        reorder_plugins(
530            target_placeholder,
531            parent_id=None,
532            language=target_language,
533            order=target_tree_order,
534        )
535        target_placeholder.mark_as_dirty(target_language, clear_cache=False)
536
537        new_plugins = CMSPlugin.objects.filter(pk__in=new_plugin_ids).order_by('path')
538        new_plugins = list(new_plugins)
539
540        self._send_post_placeholder_operation(
541            request,
542            operation=operations.ADD_PLUGINS_FROM_PLACEHOLDER,
543            token=operation_token,
544            plugins=new_plugins,
545            source_language=source_language,
546            source_placeholder=source_placeholder,
547            target_language=target_language,
548            target_placeholder=target_placeholder,
549            target_order=target_tree_order,
550        )
551        return new_plugins
552
553    @xframe_options_sameorigin
554    def edit_plugin(self, request, plugin_id):
555        try:
556            plugin_id = int(plugin_id)
557        except ValueError:
558            return HttpResponseNotFound(force_text(_("Plugin not found")))
559
560        obj = self._get_plugin_from_id(plugin_id)
561
562        # CMSPluginBase subclass instance
563        plugin_instance = obj.get_plugin_class_instance(admin=self.admin_site)
564
565        if not self.has_change_plugin_permission(request, obj):
566            return HttpResponseForbidden(force_text(_("You do not have permission to edit this plugin")))
567
568        response = plugin_instance.change_view(request, str(plugin_id))
569
570        plugin = getattr(plugin_instance, 'saved_object', None)
571
572        if plugin:
573            plugin.placeholder.mark_as_dirty(plugin.language, clear_cache=False)
574
575        if plugin_instance._operation_token:
576            self._send_post_placeholder_operation(
577                request,
578                operation=operations.CHANGE_PLUGIN,
579                token=plugin_instance._operation_token,
580                old_plugin=obj,
581                new_plugin=plugin,
582                placeholder=plugin.placeholder,
583            )
584        return response
585
586    @method_decorator(require_POST)
587    @xframe_options_sameorigin
588    @transaction.atomic
589    def move_plugin(self, request):
590        """
591        Performs a move or a "paste" operation (when «move_a_copy» is set)
592
593        POST request with following parameters:
594        - plugin_id
595        - placeholder_id
596        - plugin_language (optional)
597        - plugin_parent (optional)
598        - plugin_order (array, optional)
599        - move_a_copy (Boolean, optional) (anything supplied here except a case-
600                                        insensitive "false" is True)
601        NOTE: If move_a_copy is set, the plugin_order should contain an item
602              '__COPY__' with the desired destination of the copied plugin.
603        """
604        # plugin_id and placeholder_id are required, so, if nothing is supplied,
605        # an ValueError exception will be raised by get_int().
606        try:
607            plugin_id = get_int(request.POST.get('plugin_id'))
608        except TypeError:
609            raise RuntimeError("'plugin_id' is a required parameter.")
610
611        plugin = self._get_plugin_from_id(plugin_id)
612
613        try:
614            placeholder_id = get_int(request.POST.get('placeholder_id'))
615        except TypeError:
616            raise RuntimeError("'placeholder_id' is a required parameter.")
617        except ValueError:
618            raise RuntimeError("'placeholder_id' must be an integer string.")
619
620        placeholder = Placeholder.objects.get(pk=placeholder_id)
621
622        # The rest are optional
623        parent_id = get_int(request.POST.get('plugin_parent', ""), None)
624        target_language = request.POST['target_language']
625        move_a_copy = request.POST.get('move_a_copy')
626        move_a_copy = (move_a_copy and move_a_copy != "0" and
627                       move_a_copy.lower() != "false")
628        move_to_clipboard = placeholder == request.toolbar.clipboard
629        source_placeholder = plugin.placeholder
630
631        order = request.POST.getlist("plugin_order[]")
632
633        if placeholder != source_placeholder:
634            try:
635                template = self.get_placeholder_template(request, placeholder)
636                has_reached_plugin_limit(placeholder, plugin.plugin_type,
637                                         target_language, template=template)
638            except PluginLimitReached as er:
639                return HttpResponseBadRequest(er)
640
641        # order should be a list of plugin primary keys
642        # it's important that the plugins being referenced
643        # are all part of the same tree.
644        exclude_from_order_check = ['__COPY__', str(plugin.pk)]
645        ordered_plugin_ids = [int(pk) for pk in order if pk not in exclude_from_order_check]
646        plugins_in_tree_count = (
647            placeholder
648            .get_plugins(target_language)
649            .filter(parent=parent_id, pk__in=ordered_plugin_ids)
650            .count()
651        )
652
653        if len(ordered_plugin_ids) != plugins_in_tree_count:
654            # order does not match the tree on the db
655            message = _('order parameter references plugins in different trees')
656            return HttpResponseBadRequest(force_text(message))
657
658        # True if the plugin is not being moved from the clipboard
659        # to a placeholder or from a placeholder to the clipboard.
660        move_a_plugin = not move_a_copy and not move_to_clipboard
661
662        if parent_id and plugin.parent_id != parent_id:
663            target_parent = get_object_or_404(CMSPlugin, pk=parent_id)
664
665            if move_a_plugin and target_parent.placeholder_id != placeholder.pk:
666                return HttpResponseBadRequest(force_text(
667                    _('parent must be in the same placeholder')))
668
669            if move_a_plugin and target_parent.language != target_language:
670                return HttpResponseBadRequest(force_text(
671                    _('parent must be in the same language as '
672                      'plugin_language')))
673        elif parent_id:
674            target_parent = plugin.parent
675        else:
676            target_parent = None
677
678        new_plugin = None
679        fetch_tree = False
680
681        if move_a_copy and plugin.plugin_type == "PlaceholderPlugin":
682            new_plugins = self._paste_placeholder(
683                request,
684                plugin=plugin,
685                target_language=target_language,
686                target_placeholder=placeholder,
687                tree_order=order,
688            )
689        elif move_a_copy:
690            fetch_tree = True
691            new_plugin = self._paste_plugin(
692                request,
693                plugin=plugin,
694                target_parent=target_parent,
695                target_language=target_language,
696                target_placeholder=placeholder,
697                tree_order=order,
698            )
699        elif move_to_clipboard:
700            new_plugin = self._cut_plugin(
701                request,
702                plugin=plugin,
703                target_language=target_language,
704                target_placeholder=placeholder,
705            )
706            new_plugins = [new_plugin]
707        else:
708            fetch_tree = True
709            new_plugin = self._move_plugin(
710                request,
711                plugin=plugin,
712                target_parent=target_parent,
713                target_language=target_language,
714                target_placeholder=placeholder,
715                tree_order=order,
716            )
717
718        if new_plugin and fetch_tree:
719            root = (new_plugin.parent or new_plugin)
720            new_plugins = [root] + list(root.get_descendants().order_by('path'))
721
722        # Mark the target placeholder as dirty
723        placeholder.mark_as_dirty(target_language)
724
725        if placeholder != source_placeholder:
726            # Plugin is being moved or copied into a separate placeholder
727            # Mark source placeholder as dirty
728            source_placeholder.mark_as_dirty(plugin.language)
729        data = get_plugin_tree_as_json(request, new_plugins)
730        return HttpResponse(data, content_type='application/json')
731
732    def _paste_plugin(self, request, plugin, target_language,
733                      target_placeholder, tree_order, target_parent=None):
734        plugins = (
735            CMSPlugin
736            .get_tree(parent=plugin)
737            .filter(placeholder=plugin.placeholder_id)
738            .order_by('path')
739        )
740        plugins = list(plugins)
741
742        if not self.has_copy_from_clipboard_permission(request, target_placeholder, plugins):
743            message = force_text(_("You have no permission to paste this plugin"))
744            raise PermissionDenied(message)
745
746        if target_parent:
747            target_parent_id = target_parent.pk
748        else:
749            target_parent_id = None
750
751        target_tree_order = [int(pk) for pk in tree_order if not pk == '__COPY__']
752
753        action_token = self._send_pre_placeholder_operation(
754            request,
755            operation=operations.PASTE_PLUGIN,
756            plugin=plugin,
757            target_language=target_language,
758            target_placeholder=target_placeholder,
759            target_parent_id=target_parent_id,
760            target_order=target_tree_order,
761        )
762
763        plugin_pairs = copy_plugins.copy_plugins_to(
764            plugins,
765            to_placeholder=target_placeholder,
766            to_language=target_language,
767            parent_plugin_id=target_parent_id,
768        )
769        root_plugin = plugin_pairs[0][0]
770
771        # If an ordering was supplied, replace the item that has
772        # been copied with the new copy
773        target_tree_order.insert(tree_order.index('__COPY__'), root_plugin.pk)
774
775        reorder_plugins(
776            target_placeholder,
777            parent_id=target_parent_id,
778            language=target_language,
779            order=target_tree_order,
780        )
781        target_placeholder.mark_as_dirty(target_language, clear_cache=False)
782
783        # Fetch from db to update position and other tree values
784        root_plugin.refresh_from_db()
785
786        self._send_post_placeholder_operation(
787            request,
788            operation=operations.PASTE_PLUGIN,
789            plugin=root_plugin.get_bound_plugin(),
790            token=action_token,
791            target_language=target_language,
792            target_placeholder=target_placeholder,
793            target_parent_id=target_parent_id,
794            target_order=target_tree_order,
795        )
796        return root_plugin
797
798    def _paste_placeholder(self, request, plugin, target_language,
799                           target_placeholder, tree_order):
800        plugins = plugin.placeholder_ref.get_plugins_list()
801
802        if not self.has_copy_from_clipboard_permission(request, target_placeholder, plugins):
803            message = force_text(_("You have no permission to paste this placeholder"))
804            raise PermissionDenied(message)
805
806        target_tree_order = [int(pk) for pk in tree_order if not pk == '__COPY__']
807
808        action_token = self._send_pre_placeholder_operation(
809            request,
810            operation=operations.PASTE_PLACEHOLDER,
811            plugins=plugins,
812            target_language=target_language,
813            target_placeholder=target_placeholder,
814            target_order=target_tree_order,
815        )
816
817        new_plugins = copy_plugins.copy_plugins_to(
818            plugins,
819            to_placeholder=target_placeholder,
820            to_language=target_language,
821        )
822
823        new_plugin_ids = (new.pk for new, old in new_plugins)
824
825        # Creates a list of PKs for the top-level plugins ordered by
826        # their position.
827        top_plugins = (pair for pair in new_plugins if not pair[0].parent_id)
828        top_plugins_pks = [p[0].pk for p in sorted(top_plugins, key=lambda pair: pair[1].position)]
829
830        # If an ordering was supplied, we should replace the item that has
831        # been copied with the new plugins
832        target_tree_order[tree_order.index('__COPY__'):0] = top_plugins_pks
833
834        reorder_plugins(
835            target_placeholder,
836            parent_id=None,
837            language=target_language,
838            order=target_tree_order,
839        )
840        target_placeholder.mark_as_dirty(target_language, clear_cache=False)
841
842        new_plugins = (
843            CMSPlugin
844            .objects
845            .filter(pk__in=new_plugin_ids)
846            .order_by('path')
847            .select_related('placeholder')
848        )
849        new_plugins = list(new_plugins)
850
851        self._send_post_placeholder_operation(
852            request,
853            operation=operations.PASTE_PLACEHOLDER,
854            token=action_token,
855            plugins=new_plugins,
856            target_language=target_language,
857            target_placeholder=target_placeholder,
858            target_order=target_tree_order,
859        )
860        return new_plugins
861
862    def _move_plugin(self, request, plugin, target_language,
863                     target_placeholder, tree_order, target_parent=None):
864        if not self.has_move_plugin_permission(request, plugin, target_placeholder):
865            message = force_text(_("You have no permission to move this plugin"))
866            raise PermissionDenied(message)
867
868        plugin_data = {
869            'language': target_language,
870            'placeholder': target_placeholder,
871        }
872
873        source_language = plugin.language
874        source_placeholder = plugin.placeholder
875        source_tree_order = source_placeholder.get_plugin_tree_order(
876            language=source_language,
877            parent_id=plugin.parent_id,
878        )
879
880        if target_parent:
881            target_parent_id = target_parent.pk
882        else:
883            target_parent_id = None
884
885        if target_placeholder != source_placeholder:
886            target_tree_order = target_placeholder.get_plugin_tree_order(
887                language=target_language,
888                parent_id=target_parent_id,
889            )
890        else:
891            target_tree_order = source_tree_order
892
893        action_token = self._send_pre_placeholder_operation(
894            request,
895            operation=operations.MOVE_PLUGIN,
896            plugin=plugin,
897            source_language=source_language,
898            source_placeholder=source_placeholder,
899            source_parent_id=plugin.parent_id,
900            source_order=source_tree_order,
901            target_language=target_language,
902            target_placeholder=target_placeholder,
903            target_parent_id=target_parent_id,
904            target_order=target_tree_order,
905        )
906
907        if target_parent and plugin.parent != target_parent:
908            # Plugin is being moved to another tree (under another parent)
909            updated_plugin = plugin.update(refresh=True, parent=target_parent, **plugin_data)
910            updated_plugin = updated_plugin.move(target_parent, pos='last-child')
911        elif target_parent:
912            # Plugin is being moved within the same tree (different position, same parent)
913            updated_plugin = plugin.update(refresh=True, **plugin_data)
914        else:
915            # Plugin is being moved to the root (no parent)
916            target = CMSPlugin.get_last_root_node()
917            updated_plugin = plugin.update(refresh=True, parent=None, **plugin_data)
918            updated_plugin = updated_plugin.move(target, pos='right')
919
920        # Update all children to match the parent's
921        # language and placeholder
922        updated_plugin.get_descendants().update(**plugin_data)
923
924        # Avoid query by removing the plugin being moved
925        # from the source order
926        new_source_order = list(source_tree_order)
927        new_source_order.remove(updated_plugin.pk)
928
929        # Reorder all plugins in the target placeholder according to the
930        # passed order
931        new_target_order = [int(pk) for pk in tree_order]
932        reorder_plugins(
933            target_placeholder,
934            parent_id=target_parent_id,
935            language=target_language,
936            order=new_target_order,
937        )
938        target_placeholder.mark_as_dirty(target_language, clear_cache=False)
939
940        if source_placeholder != target_placeholder:
941            source_placeholder.mark_as_dirty(source_language, clear_cache=False)
942
943        # Refresh plugin to get new tree and position values
944        updated_plugin.refresh_from_db()
945
946        self._send_post_placeholder_operation(
947            request,
948            operation=operations.MOVE_PLUGIN,
949            plugin=updated_plugin.get_bound_plugin(),
950            token=action_token,
951            source_language=source_language,
952            source_placeholder=source_placeholder,
953            source_parent_id=plugin.parent_id,
954            source_order=new_source_order,
955            target_language=target_language,
956            target_placeholder=target_placeholder,
957            target_parent_id=target_parent_id,
958            target_order=new_target_order,
959        )
960        return updated_plugin
961
962    def _cut_plugin(self, request, plugin, target_language,  target_placeholder):
963        if not self.has_move_plugin_permission(request, plugin, target_placeholder):
964            message = force_text(_("You have no permission to cut this plugin"))
965            raise PermissionDenied(message)
966
967        plugin_data = {
968            'language': target_language,
969            'placeholder': target_placeholder,
970        }
971
972        source_language = plugin.language
973        source_placeholder = plugin.placeholder
974        source_tree_order = source_placeholder.get_plugin_tree_order(
975            language=source_language,
976            parent_id=plugin.parent_id,
977        )
978
979        action_token = self._send_pre_placeholder_operation(
980            request,
981            operation=operations.CUT_PLUGIN,
982            plugin=plugin,
983            clipboard=target_placeholder,
984            clipboard_language=target_language,
985            source_language=source_language,
986            source_placeholder=source_placeholder,
987            source_parent_id=plugin.parent_id,
988            source_order=source_tree_order,
989        )
990
991        # Empty the clipboard
992        target_placeholder.clear()
993
994        target = CMSPlugin.get_last_root_node()
995        updated_plugin = plugin.update(refresh=True, parent=None, **plugin_data)
996        updated_plugin = updated_plugin.move(target, pos='right')
997
998        # Update all children to match the parent's
999        # language and placeholder (clipboard)
1000        updated_plugin.get_descendants().update(**plugin_data)
1001
1002        # Avoid query by removing the plugin being moved
1003        # from the source order
1004        new_source_order = list(source_tree_order)
1005        new_source_order.remove(updated_plugin.pk)
1006
1007        source_placeholder.mark_as_dirty(target_language, clear_cache=False)
1008
1009        self._send_post_placeholder_operation(
1010            request,
1011            operation=operations.CUT_PLUGIN,
1012            token=action_token,
1013            plugin=updated_plugin.get_bound_plugin(),
1014            clipboard=target_placeholder,
1015            clipboard_language=target_language,
1016            source_language=source_language,
1017            source_placeholder=source_placeholder,
1018            source_parent_id=plugin.parent_id,
1019            source_order=new_source_order,
1020        )
1021        return updated_plugin
1022
1023    @xframe_options_sameorigin
1024    def delete_plugin(self, request, plugin_id):
1025        plugin = self._get_plugin_from_id(plugin_id)
1026
1027        if not self.has_delete_plugin_permission(request, plugin):
1028            return HttpResponseForbidden(force_text(
1029                _("You do not have permission to delete this plugin")))
1030
1031        opts = plugin._meta
1032        using = router.db_for_write(opts.model)
1033        if DJANGO_2_0:
1034            get_deleted_objects_additional_kwargs = {
1035                'opts': opts,
1036                'using': using,
1037                'user': request.user,
1038            }
1039        else:
1040            get_deleted_objects_additional_kwargs = {'request': request}
1041        deleted_objects, __, perms_needed, protected = get_deleted_objects(
1042            [plugin], admin_site=self.admin_site,
1043            **get_deleted_objects_additional_kwargs
1044        )
1045
1046        if request.POST:  # The user has already confirmed the deletion.
1047            if perms_needed:
1048                raise PermissionDenied(_("You do not have permission to delete this plugin"))
1049            obj_display = force_text(plugin)
1050            placeholder = plugin.placeholder
1051            plugin_tree_order = placeholder.get_plugin_tree_order(
1052                language=plugin.language,
1053                parent_id=plugin.parent_id,
1054            )
1055
1056            operation_token = self._send_pre_placeholder_operation(
1057                request,
1058                operation=operations.DELETE_PLUGIN,
1059                plugin=plugin,
1060                placeholder=placeholder,
1061                tree_order=plugin_tree_order,
1062            )
1063
1064            plugin.delete()
1065            placeholder.mark_as_dirty(plugin.language, clear_cache=False)
1066            reorder_plugins(
1067                placeholder=placeholder,
1068                parent_id=plugin.parent_id,
1069                language=plugin.language,
1070            )
1071
1072            self.log_deletion(request, plugin, obj_display)
1073            self.message_user(request, _('The %(name)s plugin "%(obj)s" was deleted successfully.') % {
1074                'name': force_text(opts.verbose_name), 'obj': force_text(obj_display)})
1075
1076            # Avoid query by removing the plugin being deleted
1077            # from the tree order list
1078            new_plugin_tree_order = list(plugin_tree_order)
1079            new_plugin_tree_order.remove(plugin.pk)
1080
1081            self._send_post_placeholder_operation(
1082                request,
1083                operation=operations.DELETE_PLUGIN,
1084                token=operation_token,
1085                plugin=plugin,
1086                placeholder=placeholder,
1087                tree_order=new_plugin_tree_order,
1088            )
1089            return HttpResponseRedirect(admin_reverse('index', current_app=self.admin_site.name))
1090
1091        plugin_name = force_text(plugin.get_plugin_class().name)
1092
1093        if perms_needed or protected:
1094            title = _("Cannot delete %(name)s") % {"name": plugin_name}
1095        else:
1096            title = _("Are you sure?")
1097        context = {
1098            "title": title,
1099            "object_name": plugin_name,
1100            "object": plugin,
1101            "deleted_objects": deleted_objects,
1102            "perms_lacking": perms_needed,
1103            "protected": protected,
1104            "opts": opts,
1105            "app_label": opts.app_label,
1106        }
1107        request.current_app = self.admin_site.name
1108        return TemplateResponse(
1109            request, "admin/cms/page/plugin/delete_confirmation.html", context
1110        )
1111
1112    @xframe_options_sameorigin
1113    def clear_placeholder(self, request, placeholder_id):
1114        placeholder = get_object_or_404(Placeholder, pk=placeholder_id)
1115        language = request.GET.get('language')
1116
1117        if placeholder.pk == request.toolbar.clipboard.pk:
1118            # User is clearing the clipboard, no need for permission
1119            # checks here as the clipboard is unique per user.
1120            # There could be a case where a plugin has relationship to
1121            # an object the user does not have permission to delete.
1122            placeholder.clear(language)
1123            return HttpResponseRedirect(admin_reverse('index', current_app=self.admin_site.name))
1124
1125        if not self.has_clear_placeholder_permission(request, placeholder, language):
1126            return HttpResponseForbidden(force_text(_("You do not have permission to clear this placeholder")))
1127
1128        opts = Placeholder._meta
1129        using = router.db_for_write(Placeholder)
1130        plugins = placeholder.get_plugins_list(language)
1131
1132        if DJANGO_2_0:
1133            get_deleted_objects_additional_kwargs = {
1134                'opts': opts,
1135                'using': using,
1136                'user': request.user,
1137            }
1138        else:
1139            get_deleted_objects_additional_kwargs = {'request': request}
1140        deleted_objects, __, perms_needed, protected = get_deleted_objects(
1141            plugins, admin_site=self.admin_site,
1142            **get_deleted_objects_additional_kwargs
1143        )
1144
1145        obj_display = force_text(placeholder)
1146
1147        if request.POST:
1148            # The user has already confirmed the deletion.
1149            if perms_needed:
1150                return HttpResponseForbidden(force_text(_("You do not have permission to clear this placeholder")))
1151
1152            operation_token = self._send_pre_placeholder_operation(
1153                request,
1154                operation=operations.CLEAR_PLACEHOLDER,
1155                plugins=plugins,
1156                placeholder=placeholder,
1157            )
1158
1159            placeholder.clear(language)
1160            placeholder.mark_as_dirty(language, clear_cache=False)
1161
1162            self.log_deletion(request, placeholder, obj_display)
1163            self.message_user(request, _('The placeholder "%(obj)s" was cleared successfully.') % {
1164                'obj': obj_display})
1165
1166            self._send_post_placeholder_operation(
1167                request,
1168                operation=operations.CLEAR_PLACEHOLDER,
1169                token=operation_token,
1170                plugins=plugins,
1171                placeholder=placeholder,
1172            )
1173            return HttpResponseRedirect(admin_reverse('index', current_app=self.admin_site.name))
1174
1175        if perms_needed or protected:
1176            title = _("Cannot delete %(name)s") % {"name": obj_display}
1177        else:
1178            title = _("Are you sure?")
1179
1180        context = {
1181            "title": title,
1182            "object_name": _("placeholder"),
1183            "object": placeholder,
1184            "deleted_objects": deleted_objects,
1185            "perms_lacking": perms_needed,
1186            "protected": protected,
1187            "opts": opts,
1188            "app_label": opts.app_label,
1189        }
1190        request.current_app = self.admin_site.name
1191        return TemplateResponse(request, "admin/cms/page/plugin/delete_confirmation.html", context)
1192