dynamicweb/cms/models/pluginmodel.py

502 lines
19 KiB
Python
Raw Normal View History

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 _("<Empty>")
@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/<plugin>/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)