How to upgrade custom plugins for django CMS v4+#
Difference between django CMS v3 and v4 plugins#
The main difference between plugins of django CMS version 3 and django CMS v4 is how the
tree is stored in the database. Up to django CMS version 3, the plugin model
CMSPlugin
inherited from a tree model MP_Node
declared in the django-treebeard library.
As of django CMS version 4, CMSPlugin
inherits directly
from django.db.models.Model
and manages the tree structure with the two fields
parent
and
position
using SQL Common Table Expressions
(CTE) which allow recursive SQL statements. Consequently all model fields originating
with treebeard are not available in django CMS v4+.
Also, the meaning of the position
field has
changed. Im django CMS v3 it was unique for each
parent
value (including None
for plugins
at root level). From django CMS v4 on, it is unique for each
placeholder
and
language
entry. Also, positions are counted
from 1 to n for all n plugins of a placeholder language combination. There must not
be gaps in the position field (i.e., a missing position value).
Warning
Since the management of the plugin tree happens within the CMS it is important to use the new placeholder API described in the section Creating and deleting plugin instances to create and delete plugins.
What to change#
The good news is that most custom plugins will not require any changes. This is unless they either directly access one of the django-treebeard fields or they create or delete plugins programmatically.
Replacing access to django-treebeard fields#
If your custom plugin accesses django-treebeard field directly, you will have to change your code. How to do this obviously depends on what your code needs to achieve. Here are some examples:
path
#
To order a queryset of plugins replace qs.orderby("path")
by
qs.orderby("position")
.
depth
#
There is no correspondence to the depth
field. If needed, it has to be computed:
@property
def depth(self):
if self.parent is None:
return 1
return self.parent.depth + 1
position
#
Often changes are made at the leaves of the tree. If you happen to know that the parent plugin does not have grant-children, the quick way to get a django CMS 3 position value is:
plugin.position - plugin.parent.position if plugin.parent else plugin.position
To calculate the position
field valid for all cases, you can use this code bit:
@property
def v3position(self):
siblings = CMSPlugin.objects.filter(parent=self.parent).orderby("position")
pos = 1
for plugin in siblings:
if plugin == self:
return pos
pos += 1
Creating or deleting plugins programmatically#
To create a plugin, first build an instance, then add it to its placeholder:
my_new_plugin = MyPluginModel(parent=None, position=1, my_config="whatever", placeholder=my_placholder)
my_placeholder.add_plugin(my_new_plugin)
This example puts the plugin at the first position if the placeholder. Those shortcuts might help:
Position |
Meaning |
---|---|
|
First child of |
|
n th child of
|
|
Last plugin in placeholder |
Warning
Do not use MyPluginModel.objects.create()
. It will almost certainly throw a
database integrity exception.
Creating “universal” plugins#
Some packages introduce universal plugins which can be used both on django CMS 3 and django CMS 4 alike. Examples include djangocms-text-ckeditor or djangocms-frontend.
Here is an excerpt from djangocms-text-ckeditor which needs to be able to create and delete child plugins for text fields. It adds private static methods to
@staticmethod
def _create_ghost_plugin(placeholder, plugin):
"""CMS version-save function to add a plugin to a placeholder"""
if hasattr(placeholder, "add_plugin"): # available as of CMS v4
placeholder.add_plugin(plugin)
else: # CMS < v4
plugin.save() # Plugin is created upon save
Similarly, it deletes plugins:
@staticmethod
def _delete_plugin(plugin):
"""Version-safe plugin delete method"""
placeholder = plugin.placeholder
if hasattr(placeholder, 'delete_plugin'): # since CMS v4
return placeholder.delete_plugin(plugin)
else:
return plugin.delete()
Note
Please consider the different counting schemes for the
position
field.
Adapting your test suite#
Test suites often create pages, add plugins that are to be tested, and publish the pages. Since publishing in django CMS 4 is not part of the core any more, a way updating the test suites is to add a test fixture to your tests that provide publish and unpublish functionality.
In the tests themselves all page.publish()
calls then need to be replaced by
self.publis(page)
calls to the fixture.
Here’s an example of test fixture (from djangocms-frontend)
from packaging.version import Version
from cms import __version__
DJANGO_CMS4 = Version(__version__) >= Version("4")
class TestFixture:
"""Sets up generic setUp and tearDown methods for tests."""
if DJANGO_CMS4: # CMS V4
def _get_version(self, grouper, version_state, language=None):
language = language or self.language
from djangocms_versioning.models import Version
versions = Version.objects.filter_by_grouper(grouper).filter(
state=version_state
)
for version in versions:
if (
hasattr(version.content, "language")
and version.content.language == language
):
return version
def publish(self, grouper, language=None):
from djangocms_versioning.constants import DRAFT
version = self._get_version(grouper, DRAFT, language)
if version is not None:
version.publish(self.superuser)
def unpublish(self, grouper, language=None):
from djangocms_versioning.constants import PUBLISHED
version = self._get_version(grouper, PUBLISHED, language)
if version is not None:
version.unpublish(self.superuser)
def create_page(self, title, **kwargs):
kwargs.setdefault("language", self.language)
kwargs.setdefault("created_by", self.superuser)
kwargs.setdefault("in_navigation", True)
kwargs.setdefault("limit_visibility_in_menu", None)
kwargs.setdefault("menu_title", title)
return create_page(title=title, **kwargs)
def get_placeholders(self, page):
return page.get_placeholders(self.language)
else: # CMS V3
def publish(self, page, language=None):
page.publish(language)
def unpublish(self, page, language=None):
page.unpublish(language)
def create_page(self, title, **kwargs):
kwargs.setdefault("language", self.language)
kwargs.setdefault("menu_title", title)
return create_page(title=title, **kwargs)
def get_placeholders(self, page):
return page.get_placeholders()