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