dynamicweb/cms/models/placeholdermodel.py

820 lines
38 KiB
Python

import warnings
from collections.abc import Iterable
from datetime import datetime, timedelta
from typing import TYPE_CHECKING
from django.contrib.contenttypes.fields import GenericForeignKey
from django.contrib.contenttypes.models import ContentType
from django.db import connection, models, transaction
from django.template.defaultfilters import title
from django.utils.encoding import force_str
from django.utils.functional import lazy
from django.utils.translation import gettext_lazy as _
from cms.cache import invalidate_cms_page_cache
from cms.cache.placeholder import clear_placeholder_cache
from cms.constants import EXPIRE_NOW, MAX_EXPIRATION_TTL
from cms.exceptions import LanguageError
from cms.models.managers import PlaceholderManager
from cms.utils import get_language_from_request, permissions
from cms.utils.conf import get_cms_setting, get_site_id
from cms.utils.i18n import get_language_object
if TYPE_CHECKING:
from cms.models.pluginmodel import CMSPlugin
class Placeholder(models.Model):
"""
``Placeholders`` can be filled with plugins, which store or generate content.
"""
#: slot name that appears in the frontend
slot = models.CharField(_("slot"), max_length=255, db_index=True, editable=False)
#: A default width is passed to the templace context as ``width``
default_width = models.PositiveSmallIntegerField(_("width"), null=True, editable=False)
content_type = models.ForeignKey(
ContentType,
blank=True,
null=True,
on_delete=models.SET_NULL,
)
object_id = models.PositiveIntegerField(blank=True, null=True)
source = GenericForeignKey("content_type", "object_id")
cache_placeholder = True #: Flag caching the palceholder's content
is_static = False #: Set to "True" for static placeholders (by the template tag)
edit_message = _("Edit object") #: Message used in the frontend editing UI for static placeholder edit link
static_message = _("This is an external placeholder") #: Message used in the frontend editing UI for static placeholder tooltip
is_editable = True #: If False the content of the placeholder is not editable in the frontend
objects = PlaceholderManager()
class Meta:
app_label = "cms"
default_permissions = []
permissions = (("use_structure", "Can use Structure mode"),)
def __str__(self):
return self.slot
def __repr__(self):
display = (
f"<{self.__module__}.{self.__class__.__name__} id={self.pk} slot='{self.slot}' object at {hex(id(self))}>"
)
return display
def clear(self, language=None):
"""Deletes all plugins from the placeholder"""
self.get_plugins(language).delete()
def get_label(self):
from cms.models import PageContent
from cms.utils.placeholder import get_placeholder_conf
template = None
if isinstance(self.source, PageContent):
# Make the database access lazy, so that it only happens if needed.
template = lazy(self.source.get_template, str)()
name = get_placeholder_conf("name", self.slot, template=template, default=title(self.slot))
name = _(name)
return name
def get_extra_context(self, template=None):
from cms.utils.placeholder import get_placeholder_conf
return get_placeholder_conf("extra_context", self.slot, template, {})
def get_extra_menu_items(self):
from cms.plugin_pool import plugin_pool
return plugin_pool.get_extra_placeholder_menu_items(self)
def has_change_permission(self, user):
"""
Returns ``True`` if user has permission to change all models attached to this placeholder.
"""
from cms.utils.permissions import get_model_permission_codename
if not self.object_id:
# technically if placeholder is not attached to anything,
# user should not be able to change it but if is superuser
# then we "should" allow it.
return user.is_superuser
try:
return self.source.has_placeholder_change_permission(user)
except AttributeError:
model = type(self.source)
change_perm = get_model_permission_codename(model, "change")
return user.has_perm(change_perm) or user.has_perm(change_perm, self.source)
def has_add_plugin_permission(self, user, plugin_type):
"""
Returns ``True`` if user has permission to add ``plugin_type`` to this placeholder.
"""
if not permissions.has_plugin_permission(user, plugin_type, "add"):
return False
if not self.has_change_permission(user):
return False
return True
def has_add_plugins_permission(self, user, plugins):
"""
Returns ``True`` if user has permission to add **all** plugins in ``plugins`` to this placeholder.
"""
if not self.has_change_permission(user):
return False
for plugin in plugins:
if not permissions.has_plugin_permission(user, plugin.plugin_type, "add"):
return False
return True
def has_change_plugin_permission(self, user, plugin):
"""
Returns ``True`` if user has permission to change ``plugin`` to this placeholder.
"""
if not permissions.has_plugin_permission(user, plugin.plugin_type, "change"):
return False
if not self.has_change_permission(user):
return False
return True
def has_delete_plugin_permission(self, user, plugin):
"""
Returns ``True`` if user has permission to delete ``plugin`` to this placeholder.
"""
if not permissions.has_plugin_permission(user, plugin.plugin_type, "delete"):
return False
if not self.has_change_permission(user):
return False
return True
def has_move_plugin_permission(self, user, plugin, target_placeholder):
"""
Returns ``True`` if user has permission to move ``plugin`` to the ``target_placeholder``.
"""
if not permissions.has_plugin_permission(user, plugin.plugin_type, "change"):
return False
if not target_placeholder.has_change_permission(user):
return False
if self != target_placeholder and not self.has_change_permission(user):
return False
return True
def has_clear_permission(self, user, languages):
"""
Returns ``True`` if user has permission to delete all plugins in this placeholder
"""
if not self.has_change_permission(user):
return False
return self.has_delete_plugins_permission(user, languages)
def has_delete_plugins_permission(self, user, languages):
"""
Returns ``True`` if user has permission to delete all plugins in this placeholder
"""
plugin_types = (
self.cmsplugin_set.filter(language__in=languages)
# exclude the clipboard plugin
.exclude(plugin_type="PlaceholderPlugin")
.values_list("plugin_type", flat=True)
.distinct()
# remove default ordering
.order_by()
)
has_permission = permissions.has_plugin_permission
for plugin_type in plugin_types.iterator():
if not has_permission(user, plugin_type, "delete"):
return False
return True
def _get_source_remote_field(self):
if self.object_id is None:
return
return next(f for f in self.source._meta.get_fields() if f.related_model == Placeholder)
def check_source(self, user):
remote_field = self._get_source_remote_field()
if remote_field is None:
return True
return remote_field.run_checks(self, user)
def _get_attached_model(self):
if self.source:
return self.source._meta.model
return None
def page_getter(self):
if not hasattr(self, "_page"):
from cms.models.contentmodels import PageContent
# Check if the GenericForeignKey is cached by looking for the _source_cache attribute
if "source" in self._state.fields_cache and isinstance(self.source, PageContent):
self._page = self.source.page
else:
try:
# Directly go through PageContent to avoid having to get the source and the page in separate queries
self._page = PageContent.admin_manager.filter(placeholders=self).select_related("page").first().page
except AttributeError:
self._page = None
return self._page
def page_setter(self, value):
self._page = value
#: Gives the page object if the placeholder belongs to a :class:`cms.models.titlemodels.PageContent` object
#: (and not to some other model.) If the placeholder is not attached to a page it returns ``None``
page = property(page_getter, page_setter)
def get_plugins_list(self, language=None):
"""Returns a list of plugins attached to this placeholder. If language is given only plugins
in the given language are returned."""
return list(self.get_plugins(language))
def get_plugins(self, language=None):
"""Returns a queryset of plugins attached to this placeholder. If language is given only plugins
in the given language are returned."""
if language:
return self.cmsplugin_set.filter(language=language)
return self.cmsplugin_set.all()
def has_plugins(self, language=None):
"""Checks if placeholder is empty (``False``) or populated (``True``)"""
return self.get_plugins(language).exists()
def get_filled_languages(self, site_id=None):
"""
Returns language objects for every language for which the placeholder
has plugins.
This is not cached as it's meant to be used in the frontend editor.
"""
if site_id is None:
site_id = get_site_id(self.page.site_id if self.page else None)
languages = []
for lang_code in set(self.get_plugins().values_list("language", flat=True)):
try:
languages.append(get_language_object(lang_code, site_id=site_id))
except LanguageError:
pass
return languages
def get_cached_plugins(self):
return getattr(self, "_plugins_cache", [])
def get_cache_expiration(self, request, response_timestamp):
"""
Returns the number of seconds (from «response_timestamp») that this
placeholder can be cached. This is derived from the plugins it contains.
This method must return: ``EXPIRE_NOW <= int <= MAX_EXPIRATION_IN_SECONDS``
:type request: HTTPRequest
:type response_timestamp: datetime
:rtype: int
"""
min_ttl = MAX_EXPIRATION_TTL
if not self.cache_placeholder or not get_cms_setting("PLUGIN_CACHE"):
# This placeholder has a plugin with an effective
# `cache = False` setting or the developer has explicitly
# disabled the PLUGIN_CACHE, so, no point in continuing.
return EXPIRE_NOW
def inner_plugin_iterator(lang):
"""
The placeholder will have a cache of all the concrete plugins it
uses already, but just in case it doesn't, we have a code-path to
generate them anew.
This is made extra private as an inner function to avoid any other
process stealing our yields.
"""
if hasattr(self, "_all_plugins_cache"):
for instance in self._all_plugins_cache:
plugin = instance.get_plugin_class_instance()
yield instance, plugin
else:
for plugin_item in self.get_plugins(lang):
yield plugin_item.get_plugin_instance()
language = get_language_from_request(request, self.page)
for instance, plugin in inner_plugin_iterator(language):
plugin_expiration = plugin.get_cache_expiration(request, instance, self)
# The plugin_expiration should only ever be either: None, a TZ-
# aware datetime, a timedelta, or an integer.
if plugin_expiration is None:
# Do not consider plugins that return None
continue
if isinstance(plugin_expiration, (datetime, timedelta)):
if isinstance(plugin_expiration, datetime):
# We need to convert this to a TTL against the
# response timestamp.
try:
delta = plugin_expiration - response_timestamp
except TypeError:
# Attempting to take the difference of a naive datetime
# and a TZ-aware one results in a TypeError. Ignore
# this plugin.
warnings.warn(
"Plugin %(plugin_class)s (%(pk)d) returned a naive "
"datetime : %(value)s for get_cache_expiration(), "
"ignoring."
% {
"plugin_class": plugin.__class__.__name__,
"pk": instance.pk,
"value": force_str(plugin_expiration),
}
)
continue
else:
# Its already a timedelta instance...
delta = plugin_expiration
ttl = int(delta.total_seconds() + 0.5)
else: # must be an int-like value
try:
ttl = int(plugin_expiration)
except ValueError:
# Looks like it was not very int-ish. Ignore this plugin.
warnings.warn(
"Plugin %(plugin_class)s (%(pk)d) returned "
"unexpected value %(value)s for "
"get_cache_expiration(), ignoring."
% {
"plugin_class": plugin.__class__.__name__,
"pk": instance.pk,
"value": force_str(plugin_expiration),
}
)
continue
min_ttl = min(ttl, min_ttl)
if min_ttl <= 0:
# No point in continuing, we've already hit the minimum
# possible expiration TTL
return EXPIRE_NOW
return min_ttl
def clear_cache(self, language, site_id=None):
if get_cms_setting("PAGE_CACHE"):
# Clears all the page caches
invalidate_cms_page_cache()
if not site_id and self.page:
site_id = self.page.site_id
clear_placeholder_cache(self, language, get_site_id(site_id))
def get_plugin_tree_order(self, language, parent_id=None):
"""
Returns a list of plugin ids matching the given language
ordered by plugin position.
"""
plugin_tree_order = (
self.get_plugins(language).filter(parent=parent_id).order_by("position").values_list("pk", flat=True)
)
return list(plugin_tree_order)
def get_vary_cache_on(self, request):
"""
Returns a list of VARY headers.
"""
def inner_plugin_iterator(lang):
"""See note in get_cache_expiration.inner_plugin_iterator()."""
if hasattr(self, "_all_plugins_cache"):
for instance in self._all_plugins_cache:
plugin = instance.get_plugin_class_instance()
yield instance, plugin
else:
for plugin_item in self.get_plugins(lang):
yield plugin_item.get_plugin_instance()
if not self.cache_placeholder or not get_cms_setting("PLUGIN_CACHE"):
return []
vary_list = set()
language = get_language_from_request(request, self.page)
for instance, plugin in inner_plugin_iterator(language):
if not instance:
continue
vary_on = plugin.get_vary_cache_on(request, instance, self)
if not vary_on:
# None, or an empty iterable
continue
if isinstance(vary_on, str):
if vary_on.lower() not in vary_list:
vary_list.add(vary_on.lower())
else:
try:
for vary_on_item in iter(vary_on):
if vary_on_item.lower() not in vary_list:
vary_list.add(vary_on_item.lower())
except TypeError:
warnings.warn(
"Plugin %(plugin_class)s (%(pk)d) returned "
"unexpected value %(value)s for "
"get_vary_cache_on(), ignoring."
% {
"plugin_class": plugin.__class__.__name__,
"pk": instance.pk,
"value": force_str(vary_on),
}
)
return sorted(list(vary_list))
def copy_plugins(self, target_placeholder, language=None, root_plugin=None):
from cms.utils.plugins import copy_plugins_to_placeholder
new_plugins = copy_plugins_to_placeholder(
plugins=self.get_plugins_list(language),
placeholder=target_placeholder,
language=language,
root_plugin=root_plugin,
)
return new_plugins
def add_plugin(self, instance: "CMSPlugin"):
"""
.. versionadded:: 4.0
Adds a plugin to the placeholder. The plugin's position field must be set to the target
position. Positions are enumerated from the start of the placeholder's plugin tree (1) to
the last plugin (*n*, where *n* is the number of plugins in the placeholder).
It is discouraged to call this method directly from outside the CMS. Use the
:func:`cms.api.add_plugin` function instead.
:param instance: Plugin to add. It's position parameter needs to be set.
:type instance: :class:`cms.models.pluginmodel.CMSPlugin` instance
.. note::
As of version 4 of django CMS the position counter does not re-start at 1 for the first
child plugin. The ``position`` field and ``language`` field are unique for a placeholder.
Example::
new_child = MyCoolPlugin()
new_child.position = (
parent_plugin.position + 1
) # add as first child: directly after parent
parent_plugin.placeholder.add(new_child)
"""
# A plugin needs a reference to the placeholder it is in.
instance.placeholder = self
last_position = self.get_last_plugin_position(instance.language) or 0
# A shift is only needed if the distance between the new plugin
# and the last plugin is greater than 1 position.
needs_shift = (instance.position - last_position) < 1
if needs_shift:
# shift to the right
shift_offset = last_position - instance.position + 2 # behind last_position plus one to shift back
self._shift_plugin_positions(
instance.language,
start=instance.position,
offset=shift_offset,
)
instance.save()
if needs_shift:
# The plugin tree was shifted to the right to make space, now squash all plugins in the
# tree to close any holes. The shift moved the former last plugin to its highest
# possible position, which is the parking offset the squash needs.
self._recalculate_plugin_positions(instance.language, base=last_position + shift_offset)
return instance
def move_plugin(self, plugin, target_position, target_placeholder=None, target_plugin=None):
"""
.. versionadded:: 4.0
Moves a plugin within the placeholder (``target_placeholder=None``) or to another placeholder.
:param plugin: Plugin to move
:type plugin: :class:`cms.models.pluginmodel.CMSPlugin` instance
:param int target_position: The plugin's new position
:param target_placeholder: Placeholder to move plugin to (or ``None``)
:type target_placeholder: :class:`cms.models.placeholdermodel.Placeholder` instance
:param target_plugin: New parent plugin (or ``None``). The target plugin must be in the same placeholder
or in the ``target_placeholder`` if one is given.
:type target_plugin: :class:`cms.models.pluginmodel.CMSPlugin` instance
The ``target_position`` is enumerated from the start of the palceholder's plugin tree (1) to
the last plugin (*n*, where *n* is the number of plugins in the placeholder).
"""
if target_placeholder:
return self._move_plugin_to_placeholder(
plugin=plugin,
target_position=target_position,
target_placeholder=target_placeholder,
target_plugin=target_plugin,
)
target_tree = self.get_plugins(plugin.language)
last_position = self.get_last_plugin_position(plugin.language) # only the position is needed
source_plugin_desc_count = plugin._get_descendants_count()
# Attn: The following line assumes that all children and grand-children have consecutive positions!
source_plugin_range = (plugin.position, plugin.position + source_plugin_desc_count)
# A single shift is enough to establish the final ordering: the moved block is left in
# place while everything it has to jump over is pushed a full tree-length out of the way.
# That already yields the correct *relative* order (the only thing the renumbering below
# depends on, since it sorts by position); the previous, complementary second shift only
# changed the absolute — and partly negative — intermediate values without affecting that
# order, so it was redundant. ``_recalculate_plugin_positions`` is order-independent and
# collision-free for any intermediate state (positive, negative or gapped), so it closes
# the resulting hole regardless. See GitHub issue #8665.
if target_position < plugin.position:
# Moving left: push everything from the target position onwards (except the moved
# block) far to the right. The block then sorts immediately after position
# ``target_position - 1``, i.e. it lands at ``target_position``.
target_tree.filter(position__gte=target_position).exclude(position__range=source_plugin_range).update(
position=models.F("position") + last_position
)
else:
# Moving right: mirror image — push everything up to the end of the target region
# (except the moved block) far to the left. The block then sorts immediately after
# them. The right edge of that region is ``target_position`` in the common case, but
# the closest node to the right of the moved block's last descendant if it has any.
target_tree.filter(position__lte=target_position + source_plugin_desc_count).exclude(
position__range=source_plugin_range
).update(position=models.F("position") - last_position)
if plugin.parent != target_plugin:
# Plugin is being moved to another tree (under another parent)
# OR plugin is being moved to the root (no parent)
plugin.update(parent=target_plugin)
# The plugin tree was shifted to make space; squash all plugin positions to close any holes.
# The left shift adds ``last_position`` to positions that are at most ``last_position``, so no
# position can exceed twice that — a safe parking offset (the right shift only subtracts, so
# it cannot exceed it either).
self._recalculate_plugin_positions(plugin.language, base=2 * last_position)
def _move_plugin_to_placeholder(self, plugin, target_position, target_placeholder, target_plugin=None):
from cms.models.pluginmodel import CMSPlugin
# Only the last positions are needed (for the parking bounds), not the whole rows.
source_last_position = self.get_last_plugin_position(plugin.language)
target_last_position = target_placeholder.get_last_plugin_position(plugin.language)
# Fetch the descendant ids once and reuse them for both the count and the bulk update; calling
# ``get_descendants()`` and then ``len()`` on it would run the descendant query twice.
descendant_ids = plugin._get_descendants_ids()
plugins_to_move_count = 1 + len(descendant_ids) # parent plus descendants
if target_last_position is not None:
# Open a gap at ``target_position`` by shifting the target's tail (positions >=
# target_position) far above its last position. A large offset keeps the shift
# collision-free — the shifted band is disjoint from every current position, so it cannot
# transiently duplicate one whatever order the rows are processed in (see #8665). The
# block drops into the freed gap and the target squash below pulls everything back to a
# dense ``1..n``, also healing any pre-existing gaps in the target.
park_offset = target_last_position + plugins_to_move_count
target_placeholder._shift_plugin_positions(plugin.language, start=target_position, offset=park_offset)
block_position = target_position
# Highest position the target now holds (the shifted tail's top); a valid parking bound.
target_base = target_last_position + park_offset
else:
# Empty target: the block simply becomes positions 1..plugins_to_move_count.
block_position = 1
target_base = plugins_to_move_count
# Move the block (parent and descendants) into the gap. The block is contiguous in the source,
# so a single uniform shift preserves its internal order; moving only the block leaves a plain
# gap in the source that the source squash below closes.
moved_position = models.F("position") + (block_position - plugin.position)
plugin.update(parent=target_plugin, placeholder=target_placeholder, position=moved_position)
# TODO: More efficient is to do raw sql update
CMSPlugin.objects.filter(pk__in=descendant_ids).update(placeholder=target_placeholder, position=moved_position)
# Squash both placeholders back to a dense 1..n: the source to close the hole the block left,
# the target to seat the block at ``target_position`` and heal any gaps. Both maxima are known
# (source can only have shrunk; target is the shifted tail's top), so neither needs a query.
self._recalculate_plugin_positions(plugin.language, base=source_last_position)
target_placeholder._recalculate_plugin_positions(plugin.language, base=target_base)
def delete_plugin(self, instance: "CMSPlugin") -> None:
"""
.. versionadded:: 4.0
Removes a plugin and its descendants from the placeholder and database.
This method **must** be used to preserver plugin tree consistency.
Do not use plugin.delete() or queryset.delete()
:param instance: Plugin to remove. Its position needs to be set.
:type instance: :class:`cms.models.pluginmodel.CMSPlugin` instance
"""
with transaction.atomic():
stats = self.get_plugins(language=instance.language).aggregate( # count + max position
count=models.Count("pk"), max_position=models.Max("position")
)
descendants = instance._get_descendants_ids() # descendant ids
self.cmsplugin_set.filter(pk__in=[instance.pk] + descendants).delete() # delete in one query
last_position = instance.position + len(descendants) # last position of the deleted block
if last_position < stats["count"]:
# The deleted block was not the trailing one, so it left a gap. The squash closes
# gaps of any size, so no shift is needed; the pre-deletion maximum (positions only
# shrink on delete) is a valid parking bound, so it needs no extra MAX/COUNT query.
self._recalculate_plugin_positions(instance.language, base=stats["max_position"])
def delete_plugins(self, instances: Iterable["CMSPlugin"]) -> None:
"""
.. versionadded:: 5.1
Removes several plugins and their descendants from the placeholder and database in a single
delete, then re-compacts the positions of each affected language once. Prefer this over
repeated :meth:`delete_plugin` calls when removing a batch of plugins.
The plugins must belong to this placeholder. Overlapping selections are fine: passing a
plugin together with one of its descendants simply deletes each row once.
:param instances: Iterable of plugins to remove.
:type instances: iterable of :class:`cms.models.pluginmodel.CMSPlugin` instances
"""
with transaction.atomic():
to_delete = set()
languages = set()
for instance in instances:
to_delete.add(instance.pk)
to_delete.update(instance._get_descendants_ids())
languages.add(instance.language)
if not to_delete:
return
self.cmsplugin_set.filter(pk__in=to_delete).delete()
# The deleted plugins may have left gaps in several blocks; re-compact each affected
# language, but only when a gap was actually opened. Positions are unique and >= 1, so a
# language is already dense exactly when its maximum equals its plugin count.
for language in languages:
stats = self.get_plugins(language=language).aggregate(
count=models.Count("pk"), max_position=models.Max("position")
)
if stats["count"] and stats["max_position"] > stats["count"]:
self._recalculate_plugin_positions(language, base=stats["max_position"])
def get_last_plugin(self, language):
return self.get_plugins(language).last()
def get_next_plugin_position(self, language, parent=None, insert_order="first"):
"""
.. versionadded:: 4.0
Helper to calculate plugin positions correctly.
:param str language: language for which the position is to be calculated
:param parent: Parent plugin or ``None`` (if position is on top level)
:type parent: :class:`cms.models.pluginmodel.CMSPlugin` instance
:param str insert_order: Either ``"first"`` (default) or ``"last"``
"""
if insert_order == "first":
position = self.get_first_plugin_position(language, parent=parent)
else:
position = self.get_last_plugin_position(language, parent=parent)
if parent and position is None:
return parent.position + 1
if insert_order == "last":
return (position or 0) + 1
return position or 1
def get_first_plugin_position(self, language, parent=None):
tree = self.get_plugins(language)
if parent:
tree = tree.filter(parent=parent)
return tree.values_list("position", flat=True).first()
def get_last_plugin_position(self, language, parent=None):
if parent is None:
tree = self.get_plugins(language)
elif parent.placeholder == self:
tree = parent.get_descendants()
else: # No last plugin if parent is not in this placeholder's plugin tree
return None
return tree.values_list("position", flat=True).last()
def _shift_plugin_positions(self, language, start, offset=None):
if offset is None:
offset = self.get_last_plugin_position(language) or 0
self.get_plugins(language).filter(position__gte=start).update(position=models.F("position") + offset)
def _recalculate_plugin_positions(self, language, base=None):
"""Closes gaps in the plugin tree by re-calculating the positions of all plugins of this
placeholder/language back to a dense ``1..n`` sequence, ordered by the current ``position``
(ties broken by ``id``).
:param base: Optional pre-computed parking offset. It only has to be an **upper bound**: any
value ``>=`` both the largest current ``position`` in the group and the number of plugins
in it is safe. Callers that just shifted by a known amount already know the resulting
maximum position and can pass it to skip the ``SELECT MAX(position), COUNT(*)`` round
trip. When ``None`` (default) it is queried.
The two phases below must run as a unit, so callers are expected to wrap the operation in a
transaction (``add_plugin``, ``move_plugin``, ``delete_plugin`` and
``copy_plugins_to_placeholder`` all do, as do the atomic admin views). The compaction runs as
two block moves:
1. *Park*: every plugin of the group is moved to its final rank ``1..n`` shifted up by
``base`` into a band ``[base + 1 .. base + n]`` that lies strictly **above** every current
position (``base >= max(position)``). Because the whole target band is above all existing
positions, no target can equal the position another, not-yet-moved row still holds, so the
statement cannot collide on the ``(placeholder, language, position)`` unique constraint —
whatever order the database processes the rows in.
2. *Unpark*: the parked band is shifted back down to ``1..n``. ``base`` is also kept ``>= n``,
so the source band ``[base + 1 .. base + n]`` and the target ``1..n`` are disjoint and this
statement cannot collide either.
Because neither phase can self-collide regardless of the database's row-processing order or
of any pre-existing gaps, this is safe under *any* starting state (including the negative or
far-shifted intermediate positions ``move_plugin`` produces) and turns a successful
recalculation into a self-heal of existing gaps. Previously the compaction ran as a single
statement that could intermittently raise an ``IntegrityError`` (duplicate position) on
PostgreSQL/SQLite — and likewise on MySQL/Oracle — whenever the position sequence contained a
gap. See GitHub issue #8665.
.. note::
The intermediate positions reach at most ``max(position) + n``. ``position`` is a signed
``SmallIntegerField``, so this stays safe well beyond any realistic plugin count.
"""
from cms.models.pluginmodel import (
CMSPlugin,
_get_database_cursor,
_get_database_vendor,
)
cursor = _get_database_cursor("write")
db_vendor = _get_database_vendor("write")
table = connection.ops.quote_name(CMSPlugin._meta.db_table)
if db_vendor not in ("sqlite", "postgresql", "mysql", "oracle"):
raise RuntimeError(f"{connection.vendor} is not supported by django-cms")
# ``base`` puts the parking band [base + 1 .. base + n] strictly above every current
# position (so phase 1 cannot collide) while staying >= n (so phase 2's source band and
# the 1..n target are disjoint and it cannot collide either). When the caller did not supply
# an upper bound, query the maximum position and plugin count to derive the tightest one.
if base is None:
cursor.execute(
f"SELECT COALESCE(MAX(position), 0), COUNT(*) FROM {table} "
"WHERE placeholder_id=%s AND language=%s",
[self.pk, language],
)
max_position, count = cursor.fetchone()
if not count:
# Nothing to recalculate (empty placeholder for this language).
return
base = max(max_position, count)
if db_vendor in ("sqlite", "postgresql"):
# Phase 1: park each row at (its rank) + base using a window function.
park_sql = (
f"UPDATE {table} "
"SET position = subquery.new_pos + %s "
"FROM ("
" SELECT id, ROW_NUMBER() OVER (ORDER BY position, id) AS new_pos "
f" FROM {table} WHERE placeholder_id=%s AND language=%s "
") subquery "
f"WHERE {table}.id=subquery.id"
)
else: # mysql, oracle
# Phase 1: park each row at (its rank) + base. ``t`` is a snapshot of the current
# positions, so the rank is computed independently of the rows already parked.
# Positions are unique within a (placeholder, language) group, so counting the rows
# strictly below each row yields a stable rank.
park_sql = (
f"UPDATE {table} "
"SET position = %s + ("
f"SELECT COUNT(*)+1 FROM (SELECT * FROM {table}) t "
f"WHERE placeholder_id={table}.placeholder_id AND language={table}.language "
f"AND {table}.position > t.position"
") WHERE placeholder_id=%s AND language=%s"
)
cursor.execute(park_sql, [base, self.pk, language])
# Phase 2: shift the whole parked band back down to a dense 1..n. Every row of the group was
# parked, so an unconditional shift by ``base`` is enough — a plain update with no window
# function, so it needs no raw SQL. It runs on the same write connection, hence inside the
# caller's transaction, right after the park above.
self.get_plugins(language).update(position=models.F("position") - base)