from __future__ import annotations import os import re import warnings from datetime import date from functools import cache, cached_property from django.core.exceptions import ObjectDoesNotExist from django.db import connection, connections, models, router from django.db.models import QuerySet from django.db.models.base import ModelBase from django.utils import timezone from django.utils.encoding import force_str from django.utils.translation import gettext_lazy as _ from cms.exceptions import DontUsePageAttributeWarning from cms.models.placeholdermodel import Placeholder from cms.utils.compat.warnings import RemovedInDjangoCMS60Warning from cms.utils.conf import get_cms_setting from cms.utils.urlutils import admin_reverse @cache def _get_descendants_cte(): db_vendor = _get_database_vendor('read') if db_vendor == 'oracle': sql = ( "WITH descendants (id, cms_plugin_position, cms_plugin_parent_id) as (" "SELECT {0}.id, {0}.position, {0}.parent_id " "FROM {0} WHERE {0}.parent_id = %s " "UNION ALL " "SELECT {0}.id, {0}.position, {0}.parent_id " "FROM descendants, {0} WHERE {0}.parent_id = descendants.id" ")" ) else: sql = ( "WITH RECURSIVE descendants as (" "SELECT {0}.id, {0}.position, {0}.parent_id " "FROM {0} WHERE {0}.parent_id = %s " "UNION ALL " "SELECT {0}.id, {0}.position, {0}.parent_id " "FROM descendants, {0} WHERE {0}.parent_id = descendants.id" ")" ) return sql.format(connection.ops.quote_name(CMSPlugin._meta.db_table)) @cache def _get_ancestors_cte(): db_vendor = _get_database_vendor('read') if db_vendor == 'oracle': sql = ( "WITH ancestors AS (" "SELECT {0}.id, {0}.parent_id " "FROM {0} WHERE id = %s " "UNION ALL " "SELECT {0}.id, {0}.parent_id FROM {0} " "INNER JOIN ancestors ON {0}.id=ancestors.parent_id" ")" ) else: sql = ( "WITH RECURSIVE ancestors AS (" "SELECT {0}.id, {0}.parent_id " "FROM {0} WHERE id = %s " "UNION ALL " "SELECT {0}.id, {0}.parent_id FROM {0} " "INNER JOIN ancestors ON {0}.id=ancestors.parent_id" ")" ) return sql.format(connection.ops.quote_name(CMSPlugin._meta.db_table)) def _get_database_connection(action): return { 'read': connections[router.db_for_read(CMSPlugin)], 'write': connections[router.db_for_write(CMSPlugin)] }[action] def _get_database_vendor(action): return _get_database_connection(action).vendor def _get_database_cursor(action): return _get_database_connection(action).cursor() class BoundRenderMeta: def __init__(self, meta): self.index = 0 self.total = 1 self.text_enabled = getattr(meta, 'text_enabled', False) class PluginModelBase(ModelBase): """ Metaclass for all CMSPlugin subclasses. This class should not be used for any other type of models. """ def __new__(cls, name, bases, attrs): super_new = super().__new__ # remove RenderMeta from the plugin class attr_meta = attrs.pop('RenderMeta', None) # Only care about subclasses of CMSPlugin # (excluding CMSPlugin itself). parents = [b for b in bases if isinstance(b, PluginModelBase)] if parents and 'cmsplugin_ptr' not in attrs: # The current class subclasses from CMSPlugin # and has not defined a cmsplugin_ptr field. meta = attrs.get('Meta', None) proxy = getattr(meta, 'proxy', False) # True if any of the base classes defines a cmsplugin_ptr field. field_is_inherited = any(hasattr(parent, 'cmsplugin_ptr') for parent in parents) # Skip proxied classes which are not autonomous ORM objects # We don't skip abstract classes because when a plugin # inherits from an abstract class, we need to make sure the # abstract class gets the correct related name, otherwise the # plugin inherits the default related name and then the # field_is_inherited check above will prevent us from adding # the fixed related name. if not proxy and not field_is_inherited: # It's important to set the field as if it was set # manually in the model class. # This is because Django will do a lot of operations # under the hood to set the forward and reverse relations. attrs['cmsplugin_ptr'] = models.OneToOneField( to='cms.CMSPlugin', name='cmsplugin_ptr', related_name='%(app_label)s_%(class)s', auto_created=True, parent_link=True, on_delete=models.CASCADE, ) # create a new class (using the super-metaclass) new_class = super_new(cls, name, bases, attrs) # if there is a RenderMeta in attrs, use this one # else try to use the one from the superclass (if present) meta = attr_meta or getattr(new_class, '_render_meta', None) # set a new BoundRenderMeta to prevent leaking of state new_class._render_meta = BoundRenderMeta(meta) return new_class class CMSPlugin(models.Model, metaclass=PluginModelBase): """ The base class for a CMS plugin model. When defining a new custom plugin, you should store plugin-instance specific information on a subclass of this class. (An example for this would be to store the number of pictures to display in a gallery.) Two restrictions apply when subclassing this to use in your own models: 1. Subclasses of CMSPlugin **cannot be further subclassed** 2. Subclasses of CMSPlugin cannot define a "text" field. """ #: :class:`django:django.db.models.ForeignKey`: Placeholder the plugin belongs to placeholder = models.ForeignKey(Placeholder, on_delete=models.CASCADE, editable=False, null=True) #: :class:`django:django.db.models.ForeignKey`: Parent plugin or ``None`` for plugins at root level in #: the placeholder parent = models.ForeignKey('self', on_delete=models.CASCADE, blank=True, null=True, editable=False) #: :class:`django:django.db.models.SmallIntegerField`: Position (unique for placeholder and language) #: starting with 1 for the first plugin in the placeholder position = models.SmallIntegerField(_("position"), default=1, editable=False) #: :class:`django:django.db.models.CharField`: Language of the plugin language = models.CharField(_("language"), max_length=15, blank=False, db_index=True, editable=False) #: `django:django.db.models.CharField`: Plugin type (name of the class as string) plugin_type = models.CharField(_("plugin_name"), max_length=50, db_index=True, editable=False) #: `django:django.db.models.DateTimeField`: Datetime the plugin was created creation_date = models.DateTimeField(_("creation date"), editable=False, default=timezone.now) #: `django:django.db.models.DateTimeField`: Datetime the plugin was last changed changed_date = models.DateTimeField(auto_now=True) #: Request-scoped list of direct child plugin instances when rendering child_plugin_instances = None #: Request-scoped list of the plugin classes that may be added as children of this instance child_class_restrictions = None class Meta: verbose_name = _("plugin") verbose_name_plural = _("plugins") app_label = 'cms' ordering = ('position',) indexes = [ models.Index(fields=['placeholder', 'language', 'position']), ] unique_together = ('placeholder', 'language', 'position') class RenderMeta: index = 0 total = 1 text_enabled = False def __str__(self): return force_str(self.pk) def __repr__(self): display = f"<{self.__module__}.{self.__class__.__name__} id={self.pk} plugin_type='{self.plugin_type}' object at {hex(id(self))}>" return display def get_plugin_name(self): return self.plugin_class.name def get_short_description(self): instance = self.get_plugin_instance()[0] if instance is not None: return force_str(instance) return _("") @cached_property def plugin_class(self): from cms.plugin_pool import plugin_pool return plugin_pool.get_plugin(self.plugin_type) def get_plugin_class(self): return self.plugin_class def get_plugin_class_instance(self, admin=None): plugin_class = self.get_plugin_class() # needed so we have the same signature as the original ModelAdmin return plugin_class(plugin_class.model, admin) def get_plugin_instance(self, admin=None): """ For a plugin instance (usually as a CMSPluginBase), this method returns the downcasted (i.e., correctly typed subclass of CMSPluginBase) instance and the plugin class :return: Tuple (instance, plugin) instance: The instance AS THE APPROPRIATE SUBCLASS OF CMSPluginBase and not necessarily just 'self', which is often just a CMSPluginBase, plugin: the associated plugin class instance (subclass of CMSPlugin) """ plugin = self.get_plugin_class_instance(admin) try: instance = self.get_bound_plugin() except ObjectDoesNotExist: instance = None self._inst = None return instance, plugin def get_bound_plugin(self): """ Returns an instance of the plugin model configured for this plugin type. """ if hasattr(self, "_inst"): return self._inst plugin = self.get_plugin_class() if plugin.model._meta.concrete_model != self.__class__._meta.concrete_model: self._inst = plugin.model.objects.get(cmsplugin_ptr=self) # Preserve prefetched placeholder self._inst._state.fields_cache["placeholder"] = self.placeholder # Preserve render_meta self._inst._render_meta = self._render_meta else: self._inst = self return self._inst def get_plugin_info(self, children=None, parents=None): plugin_class = self.plugin_class info = { 'type': 'plugin', 'position': self.position, 'placeholder_id': self.placeholder_id, 'plugin_name': force_str(self.get_plugin_name()) or '', 'plugin_type': self.plugin_type, 'plugin_id': self.pk, 'plugin_language': self.language or '', 'plugin_parent': self.parent_id or '', 'plugin_restriction': children or [], 'is_slot': plugin_class.is_slot, 'disable_child_plugins': plugin_class.disable_child_plugins, 'urls': self.get_action_urls(), } if parents is not None: warnings.warn( "The 'parents' argument of 'get_plugin_info' is deprecated and will be removed in django CMS 6.0. " "Parent plugin restrictions are no longer sent to the frontend -- droppability is derived from " "child restrictions -- so it can be safely removed from the call.", RemovedInDjangoCMS60Warning, stacklevel=2, ) # Keep emitting the key for the deprecation period so existing callers don't break. info['plugin_parent_restriction'] = parents or [] return info def refresh_from_db(self, *args, **kwargs): super().refresh_from_db(*args, **kwargs) # Delete this internal cache to let the cms populate it # on demand. try: del self._inst except AttributeError: pass def get_media_path(self, filename): page = self.placeholder.page if self.placeholder_id else None if page: return page.get_media_path(filename) return None @property def page(self): warnings.warn( "Don't use the page attribute on CMSPlugins! CMSPlugins are not " "guaranteed to have a page associated with them!", DontUsePageAttributeWarning, stacklevel=2, ) return self.placeholder.page if self.placeholder_id else None def get_instance_icon_src(self): """ Get src URL for instance's icon """ instance, plugin = self.get_plugin_instance() return plugin.icon_src(instance) if instance else '' def get_instance_icon_alt(self): """ Get alt text for instance's icon """ instance, plugin = self.get_plugin_instance() return force_str(plugin.icon_alt(instance)) if instance else '' def update(self, refresh=False, **fields): CMSPlugin.objects.filter(pk=self.pk).update(**fields) if refresh: return self.reload() return def reload(self): return CMSPlugin.objects.select_related("parent", "placeholder").get(pk=self.pk) def _get_descendants_count(self): cursor = _get_database_cursor('write') sql = _get_descendants_cte() + '\n' sql += 'SELECT COUNT(*) FROM descendants;' sql = sql.format(connection.ops.quote_name(CMSPlugin._meta.db_table)) cursor.execute(sql, [self.pk]) return cursor.fetchall()[0][0] def _get_descendants_ids(self): cursor = _get_database_cursor('write') sql = _get_descendants_cte() + '\n' sql += 'SELECT id FROM descendants;' sql = sql.format(connection.ops.quote_name(CMSPlugin._meta.db_table)) cursor.execute(sql, [self.pk]) return [item[0] for item in cursor.fetchall()] def get_children(self) -> QuerySet: return self.cmsplugin_set.all() def get_descendants(self) -> QuerySet: return CMSPlugin.objects.filter(pk__in=self._get_descendants_ids()) def get_ancestors(self) -> list[CMSPlugin]: """ Retrieve the list of ancestor plugins for the current plugin. This method returns a list of ancestor plugins, starting from the root ancestor down to the immediate parent of the current plugin. If the current plugin has no parent, an empty list is returned. Returns: list: A list of ancestor plugins, ordered from the root ancestor to the immediate parent of the current plugin. """ if not self.parent_id: return [] if self._state.fields_cache.get('parent'): return self.parent.get_ancestors() + [self.parent] return list(self.get_ancestors_qs()) def get_ancestors_qs(self) -> QuerySet: cursor = _get_database_cursor("write") sql = f"{_get_ancestors_cte()} SELECT id FROM ancestors;" cursor.execute(sql, [self.parent_id]) ancestor_ids = [item[0] for item in cursor.fetchall()] return CMSPlugin.objects.filter(pk__in=ancestor_ids).order_by('position') def set_base_attr(self, plugin): for attr in ['parent_id', 'placeholder', 'language', 'plugin_type', 'creation_date', 'pk', 'position']: setattr(plugin, attr, getattr(self, attr)) def post_copy(self, old_instance, new_old_ziplist): """ Can (should) be overridden to handle the copying of plugins which contain children plugins after the original parent has been copied. E.g., TextPlugins use this to correct the references in the text to child plugins. copied """ pass def copy_relations(self, old_instance): """ Handle copying of any relations attached to this plugin. Custom plugins have to do this themselves. See also: :ref:`Handling-Relations`, :meth:`post_copy`. :param old_instance: Source plugin instance :type old_instance: :class:`CMSPlugin` instance """ pass @classmethod def _get_related_objects(cls): fields = cls._meta._get_fields( forward=False, reverse=True, include_parents=True, include_hidden=False, ) return list(obj for obj in fields if not isinstance(obj.field, models.ManyToManyField)) def notify_on_autoadd(self, request, conf): """ Method called when we auto add this plugin via default_plugins in CMS_PLACEHOLDER_CONF. Some specific plugins may have some special stuff to do when they are auto added. """ pass def notify_on_autoadd_children(self, request, conf, children): """ Method called when we auto add children to this plugin via default_plugins//children in CMS_PLACEHOLDER_CONF. Some specific plugins may have some special stuff to do when we add children to them. ie : TextPlugin must update its content to add HTML tags to be able to see his children in WYSIWYG. """ pass def get_action_urls(self, js_compat=True): """ .. versionadd: 4.0 :return: dict of action urls for edit, add, delete, copy, and move plugin. This method replaces the set of legacy methods `get_add_url`, ``get_edit_url`, `get_move_url`, `get_delete_url`, `get_copy_url`. """ if not hasattr(CMSPlugin, '_edit_url'): CMSPlugin._edit_url = admin_reverse('cms_placeholder_edit_plugin', args=(0,)) CMSPlugin._add_url = admin_reverse('cms_placeholder_add_plugin') CMSPlugin._delete_url = admin_reverse('cms_placeholder_delete_plugin', args=(0,)) CMSPlugin._move_url = admin_reverse('cms_placeholder_move_plugin') CMSPlugin._copy_url = admin_reverse('cms_placeholder_copy_plugins') if js_compat: # TODO: Remove this condition # once the javascript files have been refactored # to use the new naming schema (ending in _url). return { 'edit_plugin': re.sub(r"/0/", f"/{self.pk}/", CMSPlugin._edit_url), 'add_plugin': CMSPlugin._add_url, 'delete_plugin': re.sub(r"/0/", f"/{self.pk}/", CMSPlugin._delete_url), 'move_plugin': CMSPlugin._move_url, 'copy_plugin': CMSPlugin._copy_url, } else: return { 'edit_url': re.sub(r"/0/", f"/{self.pk}/", CMSPlugin._edit_url), 'add_url': CMSPlugin._add_url, 'delete_url': re.sub(r"/0/", f"/{self.pk}/", CMSPlugin._delete_url), 'move_url': CMSPlugin._move_url, 'copy_url': CMSPlugin._copy_url, } def get_plugin_media_path(instance, filename): """ Django requires that unbound function used in fields' definitions to be defined outside the parent class. (see https://docs.djangoproject.com/en/dev/topics/migrations/#serializing-values) This function is used within field definition: file = models.FileField(_("file"), upload_to=get_plugin_media_path) and it invokes the bounded method on the given instance at runtime """ return instance.get_media_path(filename)