1# -*- coding: utf-8 -*-
2import json
3import re
4
5from django.shortcuts import render_to_response
6
7from django import forms
8from django.contrib import admin
9from django.contrib import messages
10from django.core.exceptions import (
11    ImproperlyConfigured,
12    ObjectDoesNotExist,
13    ValidationError,
14)
15from django.utils import six
16from django.utils.encoding import force_text, python_2_unicode_compatible, smart_str
17from django.utils.html import escapejs
18from django.utils.translation import ugettext, ugettext_lazy as _
19
20from cms import operations
21from cms.exceptions import SubClassNeededError
22from cms.models import CMSPlugin
23from cms.toolbar.utils import get_plugin_tree_as_json, get_plugin_toolbar_info
24from cms.utils.conf import get_cms_setting
25
26
27class CMSPluginBaseMetaclass(forms.MediaDefiningClass):
28    """
29    Ensure the CMSPlugin subclasses have sane values and set some defaults if
30    they're not given.
31    """
32    def __new__(cls, name, bases, attrs):
33        super_new = super(CMSPluginBaseMetaclass, cls).__new__
34        parents = [base for base in bases if isinstance(base, CMSPluginBaseMetaclass)]
35        if not parents:
36            # If this is CMSPluginBase itself, and not a subclass, don't do anything
37            return super_new(cls, name, bases, attrs)
38        new_plugin = super_new(cls, name, bases, attrs)
39        # validate model is actually a CMSPlugin subclass.
40        if not issubclass(new_plugin.model, CMSPlugin):
41            raise SubClassNeededError(
42                "The 'model' attribute on CMSPluginBase subclasses must be "
43                "either CMSPlugin or a subclass of CMSPlugin. %r on %r is not."
44                % (new_plugin.model, new_plugin)
45            )
46        # validate the template:
47        if (not hasattr(new_plugin, 'render_template') and
48                not hasattr(new_plugin, 'get_render_template')):
49            raise ImproperlyConfigured(
50                "CMSPluginBase subclasses must have a render_template attribute"
51                " or get_render_template method"
52            )
53        # Set the default form
54        if not new_plugin.form:
55            form_meta_attrs = {
56                'model': new_plugin.model,
57                'exclude': ('position', 'placeholder', 'language', 'plugin_type', 'path', 'depth')
58            }
59            form_attrs = {
60                'Meta': type('Meta', (object,), form_meta_attrs)
61            }
62            new_plugin.form = type('%sForm' % name, (forms.ModelForm,), form_attrs)
63        # Set the default fieldsets
64        if not new_plugin.fieldsets:
65            basic_fields = []
66            advanced_fields = []
67            for f in new_plugin.model._meta.fields:
68                if not f.auto_created and f.editable:
69                    if hasattr(f, 'advanced'):
70                        advanced_fields.append(f.name)
71                    else:
72                        basic_fields.append(f.name)
73            if advanced_fields:
74                new_plugin.fieldsets = [
75                    (
76                        None,
77                        {
78                            'fields': basic_fields
79                        }
80                    ),
81                    (
82                        _('Advanced options'),
83                        {
84                            'fields': advanced_fields,
85                            'classes': ('collapse',)
86                        }
87                    )
88                ]
89        # Set default name
90        if not new_plugin.name:
91            new_plugin.name = re.sub("([a-z])([A-Z])", "\g<1> \g<2>", name)
92
93        # By flagging the plugin class, we avoid having to call these class
94        # methods for every plugin all the time.
95        # Instead, we only call them if they are actually overridden.
96        if 'get_extra_placeholder_menu_items' in attrs:
97            new_plugin._has_extra_placeholder_menu_items = True
98
99        if 'get_extra_plugin_menu_items' in attrs:
100            new_plugin._has_extra_plugin_menu_items = True
101        return new_plugin
102
103
104@python_2_unicode_compatible
105class CMSPluginBase(six.with_metaclass(CMSPluginBaseMetaclass, admin.ModelAdmin)):
106
107    name = ""
108    module = _("Generic")  # To be overridden in child classes
109
110    form = None
111    change_form_template = "admin/cms/page/plugin/change_form.html"
112    # Should the plugin be rendered in the admin?
113    admin_preview = False
114
115    render_template = None
116
117    # Should the plugin be rendered at all, or doesn't it have any output?
118    render_plugin = True
119
120    model = CMSPlugin
121    text_enabled = False
122    page_only = False
123
124    allow_children = False
125    child_classes = None
126
127    require_parent = False
128    parent_classes = None
129
130    disable_child_plugins = False
131
132    # Warning: setting these to False, may have a serious performance impact,
133    # because their child-parent-relation must be recomputed each
134    # time the plugin tree is rendered.
135    cache_child_classes = True
136    cache_parent_classes = True
137
138    _has_extra_placeholder_menu_items = False
139    _has_extra_plugin_menu_items = False
140
141    cache = get_cms_setting('PLUGIN_CACHE')
142    system = False
143
144    opts = {}
145
146    def __init__(self, model=None, admin_site=None):
147        if admin_site:
148            super(CMSPluginBase, self).__init__(self.model, admin_site)
149
150        self.object_successfully_changed = False
151        self.placeholder = None
152        self.page = None
153        self.cms_plugin_instance = None
154        # The _cms_initial_attributes acts as a hook to set
155        # certain values when the form is saved.
156        # Currently this only happens on plugin creation.
157        self._cms_initial_attributes = {}
158        self._operation_token = None
159
160    def _get_render_template(self, context, instance, placeholder):
161        if hasattr(self, 'get_render_template'):
162            template = self.get_render_template(context, instance, placeholder)
163        elif getattr(self, 'render_template', False):
164            template = getattr(self, 'render_template', False)
165        else:
166            template = None
167
168        if not template:
169            raise ValidationError("plugin has no render_template: %s" % self.__class__)
170        return template
171
172    @classmethod
173    def get_render_queryset(cls):
174        return cls.model._default_manager.all()
175
176    def render(self, context, instance, placeholder):
177        context['instance'] = instance
178        context['placeholder'] = placeholder
179        return context
180
181    @classmethod
182    def requires_parent_plugin(cls, slot, page):
183        if cls.get_require_parent(slot, page):
184            return True
185
186        allowed_parents = cls.get_parent_classes(slot, page)
187        return bool(allowed_parents)
188
189    @classmethod
190    def get_require_parent(cls, slot, page):
191        from cms.utils.placeholder import get_placeholder_conf
192
193        template = page.get_template() if page else None
194
195        # config overrides..
196        require_parent = get_placeholder_conf('require_parent', slot, template, default=cls.require_parent)
197        return require_parent
198
199    def get_cache_expiration(self, request, instance, placeholder):
200        """
201        Provides hints to the placeholder, and in turn to the page for
202        determining the appropriate Cache-Control headers to add to the
203        HTTPResponse object.
204
205        Must return one of:
206            - None: This means the placeholder and the page will not even
207              consider this plugin when calculating the page expiration;
208
209            - A TZ-aware `datetime` of a specific date and time in the future
210              when this plugin's content expires;
211
212            - A `datetime.timedelta` instance indicating how long, relative to
213              the response timestamp that the content can be cached;
214
215            - An integer number of seconds that this plugin's content can be
216              cached.
217
218        There are constants are defined in `cms.constants` that may be helpful:
219            - `EXPIRE_NOW`
220            - `MAX_EXPIRATION_TTL`
221
222        An integer value of 0 (zero) or `EXPIRE_NOW` effectively means "do not
223        cache". Negative values will be treated as `EXPIRE_NOW`. Values
224        exceeding the value `MAX_EXPIRATION_TTL` will be set to that value.
225
226        Negative `timedelta` values or those greater than `MAX_EXPIRATION_TTL`
227        will also be ranged in the same manner.
228
229        Similarly, `datetime` values earlier than now will be treated as
230        `EXPIRE_NOW`. Values greater than `MAX_EXPIRATION_TTL` seconds in the
231        future will be treated as `MAX_EXPIRATION_TTL` seconds in the future.
232        """
233        return None
234
235    def get_vary_cache_on(self, request, instance, placeholder):
236        """
237        Provides hints to the placeholder, and in turn to the page for
238        determining VARY headers for the response.
239
240        Must return one of:
241            - None (default),
242            - String of a case-sensitive header name, or
243            - iterable of case-sensitive header names.
244
245        NOTE: This only makes sense to use with caching. If this plugin has
246        ``cache = False`` or plugin.get_cache_expiration(...) returns 0,
247        get_vary_cache_on() will have no effect.
248        """
249        return None
250
251    def render_change_form(self, request, context, add=False, change=False, form_url='', obj=None):
252        """
253        We just need the popup interface here
254        """
255        context.update({
256            'preview': "no_preview" not in request.GET,
257            'is_popup': True,
258            'plugin': obj,
259            'CMS_MEDIA_URL': get_cms_setting('MEDIA_URL'),
260        })
261
262        return super(CMSPluginBase, self).render_change_form(request, context, add, change, form_url, obj)
263
264    def render_close_frame(self, request, obj, extra_context=None):
265        try:
266            root = obj.parent.get_bound_plugin() if obj.parent else obj
267        except ObjectDoesNotExist:
268            # This is a nasty edge-case.
269            # If the parent plugin is a ghost plugin, fetching the plugin tree
270            # will fail because the downcasting function filters out all ghost plugins.
271            # Currently this case is only present in the djangocms-text-ckeditor app
272            # which uses ghost plugins to create inline plugins on the text.
273            root = obj
274
275        plugins = [root] + list(root.get_descendants().order_by('path'))
276
277        child_classes = self.get_child_classes(
278            slot=obj.placeholder.slot,
279            page=obj.page,
280            instance=obj,
281        )
282
283        parent_classes = self.get_parent_classes(
284            slot=obj.placeholder.slot,
285            page=obj.page,
286            instance=obj,
287        )
288
289        data = get_plugin_toolbar_info(
290            obj,
291            children=child_classes,
292            parents=parent_classes,
293        )
294        data['plugin_desc'] = escapejs(force_text(obj.get_short_description()))
295
296        context = {
297            'plugin': obj,
298            'is_popup': True,
299            'plugin_data': json.dumps(data),
300            'plugin_structure': get_plugin_tree_as_json(request, plugins),
301        }
302
303        if extra_context:
304            context.update(extra_context)
305        return render_to_response(
306            'admin/cms/page/plugin/confirm_form.html', context
307        )
308
309    def save_model(self, request, obj, form, change):
310        """
311        Override original method, and add some attributes to obj
312        This have to be made, because if object is newly created, he must know
313        where he lives.
314        """
315        pl_admin = obj.placeholder._get_attached_admin()
316
317        if pl_admin:
318            operation_kwargs = {
319                'request': request,
320                'placeholder': obj.placeholder,
321            }
322
323            if change:
324                operation_kwargs['old_plugin'] = self.model.objects.get(pk=obj.pk)
325                operation_kwargs['new_plugin'] = obj
326                operation_kwargs['operation'] = operations.CHANGE_PLUGIN
327            else:
328                parent_id = obj.parent.pk if obj.parent else None
329                tree_order = obj.placeholder.get_plugin_tree_order(parent_id)
330                operation_kwargs['plugin'] = obj
331                operation_kwargs['operation'] = operations.ADD_PLUGIN
332                operation_kwargs['tree_order'] = tree_order
333            # Remember the operation token
334            self._operation_token = pl_admin._send_pre_placeholder_operation(**operation_kwargs)
335
336        # remember the saved object
337        self.saved_object = obj
338        return super(CMSPluginBase, self).save_model(request, obj, form, change)
339
340    def save_form(self, request, form, change):
341        obj = super(CMSPluginBase, self).save_form(request, form, change)
342
343        for field, value in self._cms_initial_attributes.items():
344            # Set the initial attribute hooks (if any)
345            setattr(obj, field, value)
346        return obj
347
348    def response_add(self, request, obj, **kwargs):
349        self.object_successfully_changed = True
350        # Normally we would add the user message to say the object
351        # was added successfully but looks like the CMS has not
352        # supported this and can lead to issues with plugins
353        # like ckeditor.
354        return self.render_close_frame(request, obj)
355
356    def response_change(self, request, obj):
357        self.object_successfully_changed = True
358        opts = self.model._meta
359        msg_dict = {'name': force_text(opts.verbose_name), 'obj': force_text(obj)}
360        msg = _('The %(name)s "%(obj)s" was changed successfully.') % msg_dict
361        self.message_user(request, msg, messages.SUCCESS)
362        return self.render_close_frame(request, obj)
363
364    def log_addition(self, request, obj, bypass=None):
365        pass
366
367    def log_change(self, request, obj, message, bypass=None):
368        pass
369
370    def log_deletion(self, request, obj, object_repr, bypass=None):
371        pass
372
373    def icon_src(self, instance):
374        """
375        Overwrite this if text_enabled = True
376
377        Return the URL for an image to be used for an icon for this
378        plugin instance in a text editor.
379        """
380        return ""
381
382    def icon_alt(self, instance):
383        """
384        Overwrite this if necessary if text_enabled = True
385        Return the 'alt' text to be used for an icon representing
386        the plugin object in a text editor.
387        """
388        return "%s - %s" % (force_text(self.name), force_text(instance))
389
390    def get_fieldsets(self, request, obj=None):
391        """
392        Same as from base class except if there are no fields, show an info message.
393        """
394        fieldsets = super(CMSPluginBase, self).get_fieldsets(request, obj)
395
396        for name, data in fieldsets:
397            if data.get('fields'):  # if fieldset with non-empty fields is found, return fieldsets
398                return fieldsets
399
400        if self.inlines:
401            return []  # if plugin has inlines but no own fields return empty fieldsets to remove empty white fieldset
402
403        try:  # if all fieldsets are empty (assuming there is only one fieldset then) add description
404            fieldsets[0][1]['description'] = self.get_empty_change_form_text(obj=obj)
405        except KeyError:
406            pass
407        return fieldsets
408
409    @classmethod
410    def get_empty_change_form_text(cls, obj=None):
411        """
412        Returns the text displayed to the user when editing a plugin
413        that requires no configuration.
414        """
415        return ugettext('There are no further settings for this plugin. Please press save.')
416
417    @classmethod
418    def get_child_class_overrides(cls, slot, page):
419        """
420        Returns a list of plugin types that are allowed
421        as children of this plugin.
422        """
423        from cms.utils.placeholder import get_placeholder_conf
424
425        template = page.get_template() if page else None
426
427        # config overrides..
428        ph_conf = get_placeholder_conf('child_classes', slot, template, default={})
429        return ph_conf.get(cls.__name__, cls.child_classes)
430
431    @classmethod
432    def get_child_plugin_candidates(cls, slot, page):
433        """
434        Returns a list of all plugin classes
435        that will be considered when fetching
436        all available child classes for this plugin.
437        """
438        # Adding this as a separate method,
439        # we allow other plugins to affect
440        # the list of child plugin candidates.
441        # Useful in cases like djangocms-text-ckeditor
442        # where only text only plugins are allowed.
443        from cms.plugin_pool import plugin_pool
444        return plugin_pool.registered_plugins
445
446    @classmethod
447    def get_child_classes(cls, slot, page, instance=None):
448        """
449        Returns a list of plugin types that can be added
450        as children to this plugin.
451        """
452        # Placeholder overrides are highest in priority
453        child_classes = cls.get_child_class_overrides(slot, page)
454
455        if child_classes:
456            return child_classes
457
458        # Get all child plugin candidates
459        installed_plugins = cls.get_child_plugin_candidates(slot, page)
460
461        child_classes = []
462        plugin_type = cls.__name__
463
464        # The following will go through each
465        # child plugin candidate and check if
466        # has configured parent class restrictions.
467        # If there are restrictions then the plugin
468        # is only a valid child class if the current plugin
469        # matches one of the parent restrictions.
470        # If there are no restrictions then the plugin
471        # is a valid child class.
472        for plugin_class in installed_plugins:
473            allowed_parents = plugin_class.get_parent_classes(slot, page, instance)
474            if not allowed_parents or plugin_type in allowed_parents:
475                # Plugin has no parent restrictions or
476                # Current plugin (self) is a configured parent
477                child_classes.append(plugin_class.__name__)
478
479        return child_classes
480
481    @classmethod
482    def get_parent_classes(cls, slot, page, instance=None):
483        from cms.utils.placeholder import get_placeholder_conf
484
485        template = page.get_template() if page else None
486
487        # config overrides..
488        ph_conf = get_placeholder_conf('parent_classes', slot, template, default={})
489        parent_classes = ph_conf.get(cls.__name__, cls.parent_classes)
490        return parent_classes
491
492    def get_plugin_urls(self):
493        """
494        Return URL patterns for which the plugin wants to register
495        views for.
496        """
497        return []
498
499    def plugin_urls(self):
500        return self.get_plugin_urls()
501    plugin_urls = property(plugin_urls)
502
503    @classmethod
504    def get_extra_placeholder_menu_items(self, request, placeholder):
505        pass
506
507    @classmethod
508    def get_extra_plugin_menu_items(cls, request, plugin):
509        pass
510
511    def __repr__(self):
512        return smart_str(self.name)
513
514    def __str__(self):
515        return self.name
516
517
518class PluginMenuItem(object):
519
520    def __init__(self, name, url, data=None, question=None, action='ajax', attributes=None):
521        """
522        Creates an item in the plugin / placeholder menu
523
524        :param name: Item name (label)
525        :param url: URL the item points to. This URL will be called using POST
526        :param data: Data to be POSTed to the above URL
527        :param question: Confirmation text to be shown to the user prior to call the given URL (optional)
528        :param action: Custom action to be called on click; currently supported: 'ajax', 'ajax_add'
529        :param attributes: Dictionary whose content will be added as data-attributes to the menu item
530        """
531        if not attributes:
532            attributes = {}
533
534        if data:
535            data = json.dumps(data)
536
537        self.name = name
538        self.url = url
539        self.data = data
540        self.question = question
541        self.action = action
542        self.attributes = attributes
543