From 3d09c6948e3f29ce5a110bbb8b7c7f3071ff18af Mon Sep 17 00:00:00 2001 From: Fabian Braun Date: Wed, 21 Dec 2022 18:55:27 +0100 Subject: [PATCH] feat: Universal support for django CMS v3 and v4 (#631) * add django-cms 4 support to main branch * Fix: render plugin test to use LinkPlugin in stead of PicturePlugin (since newer versions of easythumbnailer return float instead of int) Add: Tests for django CMS 4 --- .github/workflows/test.yml | 23 +- CHANGELOG.rst | 1 + README.rst | 7 +- aldryn_config.py | 3 +- djangocms_text_ckeditor/apps.py | 1 + djangocms_text_ckeditor/cms_plugins.py | 42 +- djangocms_text_ckeditor/compat.py | 6 - djangocms_text_ckeditor/forms.py | 14 +- djangocms_text_ckeditor/models.py | 27 +- .../ckeditor_plugins/cmsplugins/plugin.js | 25 +- ... => bundle-ab7ff62c5a.cms.ckeditor.min.js} | 2 +- djangocms_text_ckeditor/utils.py | 10 + djangocms_text_ckeditor/widgets.py | 14 +- gulpfile.js | 6 +- package-lock.json | 471 ------------------ setup.py | 1 + tests/fixtures.py | 95 ++++ tests/requirements/base.txt | 8 +- tests/requirements/dj22_cms40.txt | 4 + tests/requirements/dj32_cms310.txt | 2 +- tests/requirements/dj32_cms311.txt | 4 + tests/requirements/dj32_cms41.txt | 4 + tests/requirements/dj40_cms311.txt | 4 + tests/requirements/dj40_cms41.txt | 4 + tests/requirements/dj41_cms311.txt | 4 + tests/requirements/dj41_cms41.txt | 4 + tests/settings.py | 8 + tests/test_migrations.py | 2 +- tests/test_plugin.py | 129 +++-- tests/test_widget.py | 41 +- tox.ini | 15 +- 31 files changed, 365 insertions(+), 616 deletions(-) delete mode 100644 djangocms_text_ckeditor/compat.py rename djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/{bundle-5f73f48756.cms.ckeditor.min.js => bundle-ab7ff62c5a.cms.ckeditor.min.js} (98%) create mode 100644 tests/fixtures.py create mode 100644 tests/requirements/dj22_cms40.txt create mode 100644 tests/requirements/dj32_cms311.txt create mode 100644 tests/requirements/dj32_cms41.txt create mode 100644 tests/requirements/dj40_cms311.txt create mode 100644 tests/requirements/dj40_cms41.txt create mode 100644 tests/requirements/dj41_cms311.txt create mode 100644 tests/requirements/dj41_cms41.txt diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 627eb644c..c66c2a350 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -8,18 +8,35 @@ jobs: strategy: fail-fast: false matrix: - python-version: [ 3.7, 3.8, 3.9, '3.10'] + python-version: [ 3.7, 3.8, 3.9, '3.10', "3.11"] requirements-file: [ dj22_cms37.txt, dj22_cms38.txt, + dj22_cms40.txt, dj31_cms38.txt, dj32_cms39.txt, - dj32_cms310.txt + dj32_cms310.txt, + dj32_cms311.txt, + dj32_cms41.txt, + dj40_cms311.txt, + dj40_cms41.txt ] os: [ ubuntu-20.04, ] - + exclude: + - python-version: 3.7 + requirements-file: dj40_cms311.txt + - python-version: 3.7 + requirements-file: dj40_cms41.txt + - python-version: 3.7 + requirements-file: dj41_cms311.txt + - python-version: 3.7 + requirements-file: dj41_cms41.txt + - python-version: "3.10" + requirements-file: dj22_cms40.txt + - python-version: "3.11" + requirements-file: dj22_cms40.txt steps: - uses: actions/checkout@v1 - name: Set up Python ${{ matrix.python-version }} diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 30f927c3c..36d3b2944 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,7 @@ Changelog Unreleased ========== +* Add suport for django CMS 4 * Fix `468 `_ via `637 `_: Delay importing models.CMSPlugin in utils to allow adding an HTMLField to a custom user model. diff --git a/README.rst b/README.rst index 3d9fdebdb..506903d93 100644 --- a/README.rst +++ b/README.rst @@ -2,7 +2,7 @@ django CMS Text CKEditor ======================== -|pypi| |coverage| |python| |django| |djangocms| +|pypi| |coverage| |python| |django| |djangocms| |djangocms4| .. note:: @@ -512,10 +512,11 @@ You can run tests by executing:: :target: https://travis-ci.org/divio/djangocms-text-ckeditor .. |coverage| image:: https://codecov.io/gh/django-cms/djangocms-text-ckeditor/branch/master/graph/badge.svg :target: https://codecov.io/gh/django-cms/djangocms-text-ckeditor - .. |python| image:: https://img.shields.io/badge/python-3.7+-blue.svg :target: https://pypi.org/project/djangocms-text-ckeditor/ -.. |django| image:: https://img.shields.io/badge/django-2.2,%203.1,%203.2-blue.svg +.. |django| image:: https://img.shields.io/badge/django-2.2--4.0-blue.svg :target: https://www.djangoproject.com/ .. |djangocms| image:: https://img.shields.io/badge/django%20CMS-3.7%2B-blue.svg :target: https://www.django-cms.org/ +.. |djangocms4| image:: https://img.shields.io/badge/django%20CMS-4-blue.svg + :target: https://www.django-cms.org/ diff --git a/aldryn_config.py b/aldryn_config.py index 9cb04a0d9..77441a185 100644 --- a/aldryn_config.py +++ b/aldryn_config.py @@ -32,10 +32,9 @@ def to_settings(self, data, settings): else: ckeditor_settings['contentsCss'] = ['/static/css/base.css'] + style_set = '' if data.get('style_set'): style_set = data['style_set'] - else: - style_set = '' ckeditor_settings['stylesSet'] = f'default:{style_set}' diff --git a/djangocms_text_ckeditor/apps.py b/djangocms_text_ckeditor/apps.py index 5fa5dccfe..64e7eb3ef 100644 --- a/djangocms_text_ckeditor/apps.py +++ b/djangocms_text_ckeditor/apps.py @@ -4,3 +4,4 @@ class TextCkeditorConfig(AppConfig): name = 'djangocms_text_ckeditor' verbose_name = 'django CMS Text CKEditor' + default_auto_field = 'django.db.models.AutoField' diff --git a/djangocms_text_ckeditor/cms_plugins.py b/djangocms_text_ckeditor/cms_plugins.py index efb07d34a..11d7e73b4 100644 --- a/djangocms_text_ckeditor/cms_plugins.py +++ b/djangocms_text_ckeditor/cms_plugins.py @@ -1,7 +1,6 @@ import json import operator import re -from distutils.version import LooseVersion from django.contrib.admin.utils import unquote from django.core import signing @@ -19,7 +18,6 @@ from django.views.decorators.clickjacking import xframe_options_sameorigin from django.views.decorators.http import require_POST -import cms from cms.models import CMSPlugin from cms.plugin_base import CMSPluginBase from cms.plugin_pool import plugin_pool @@ -30,21 +28,12 @@ from .forms import ActionTokenValidationForm, DeleteOnCancelForm, RenderPluginForm, TextForm from .models import Text from .utils import ( - OBJ_ADMIN_WITH_CONTENT_RE_PATTERN, _plugin_tags_to_html, plugin_tags_to_admin_html, plugin_tags_to_id_list, - plugin_tags_to_user_html, plugin_to_tag, random_comment_exempt, replace_plugin_tags, + OBJ_ADMIN_WITH_CONTENT_RE_PATTERN, _plugin_tags_to_html, cms_placeholder_add_plugin, plugin_tags_to_admin_html, + plugin_tags_to_id_list, plugin_tags_to_user_html, plugin_to_tag, random_comment_exempt, replace_plugin_tags, ) from .widgets import TextEditorWidget -CMS_34 = LooseVersion(cms.__version__) >= LooseVersion("3.4") - - -def _user_can_change_placeholder(request, placeholder): - if CMS_34: - return placeholder.has_change_permission(request.user) - return placeholder.has_change_permission(request) - - def post_add_plugin(operation, **kwargs): from djangocms_history.actions import ADD_PLUGIN from djangocms_history.helpers import get_bound_plugins, get_plugin_data @@ -187,10 +176,9 @@ class TextPlugin(CMSPluginBase): "pre_change_plugin": pre_change_plugin, } - if CMS_34: - # On django CMS 3.5 this attribute is set automatically - # when do_post_copy is defined in the plugin class. - _has_do_post_copy = True + # On django CMS 3.5 this attribute is set automatically + # when do_post_copy is defined in the plugin class. + _has_do_post_copy = True @classmethod def do_post_copy(cls, instance, source_map): @@ -251,6 +239,7 @@ def get_editor_widget(self, request, plugins, plugin): pk=plugin.pk, placeholder=plugin.placeholder, plugin_language=plugin.language, + plugin_position=plugin.position, configuration=self.ckeditor_configuration, render_plugin_url=render_plugin_url, cancel_url=cancel_url, @@ -331,6 +320,14 @@ def __init__(self, *args, **kwargs): return TextPluginForm + @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() + @xframe_options_sameorigin def add_view(self, request, form_url="", extra_context=None): if "plugin" in request.GET: @@ -381,18 +378,19 @@ def add_view(self, request, form_url="", extra_context=None): # Sadly we have to create the CMSPlugin record on add GET request # because we need this record in order to allow the user to add # child plugins to the text (image, link, etc..) - plugin = CMSPlugin.objects.create( + plugin = CMSPlugin( language=data["plugin_language"], plugin_type=data["plugin_type"], - position=data["position"], placeholder=data["placeholder_id"], + position=data["position"], parent=data.get("plugin_parent"), ) + self._create_ghost_plugin(data["placeholder_id"], plugin) query = request.GET.copy() query["plugin"] = str(plugin.pk) - success_url = admin_reverse("cms_page_add_plugin") + success_url = admin_reverse(cms_placeholder_add_plugin) # Version dependent # Because we've created the cmsplugin record # we need to delete the plugin when a user cancels. success_url += "?delete-on-cancel&" + query.urlencode() @@ -449,7 +447,7 @@ def render_plugin(self, request): if not ( plugin_class.has_change_permission(request, obj=text_plugin) - and _user_can_change_placeholder(request, text_plugin.placeholder) # noqa + and text_plugin.placeholder.has_change_permission(request.user) # noqa ): raise PermissionDenied return HttpResponse(form.render_plugin(request)) @@ -486,7 +484,7 @@ def delete_on_cancel(self, request): # and the ckeditor plugin itself. if not ( plugin_class.has_add_permission(request) - and _user_can_change_placeholder(request, text_plugin.placeholder) # noqa + and text_plugin.placeholder.has_change_permission(request.user) # noqa ): raise PermissionDenied # Token is validated after checking permissions diff --git a/djangocms_text_ckeditor/compat.py b/djangocms_text_ckeditor/compat.py deleted file mode 100644 index 31b5997f7..000000000 --- a/djangocms_text_ckeditor/compat.py +++ /dev/null @@ -1,6 +0,0 @@ -def get_page_placeholders(page, language=None): - try: - # cms 3.6 compat - return page.get_placeholders() - except TypeError: - return page.get_placeholders(language) diff --git a/djangocms_text_ckeditor/forms.py b/djangocms_text_ckeditor/forms.py index 06905d3b6..3c7777845 100644 --- a/djangocms_text_ckeditor/forms.py +++ b/djangocms_text_ckeditor/forms.py @@ -88,13 +88,23 @@ def get_child_plugins(self): queryset = queryset.exclude(pk__in=excluded_plugins) return queryset + @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() + def delete(self): child_plugins = self.cleaned_data.get('child_plugins') if child_plugins: - child_plugins.delete() + for child in child_plugins: + self._delete_plugin(child) else: - self.text_plugin.delete() + self._delete_plugin(self.text_plugin) class TextForm(ModelForm): diff --git a/djangocms_text_ckeditor/models.py b/djangocms_text_ckeditor/models.py index 845610e74..a2a7c3e07 100644 --- a/djangocms_text_ckeditor/models.py +++ b/djangocms_text_ckeditor/models.py @@ -1,3 +1,5 @@ +from copy import deepcopy + from django.db import models from django.utils.encoding import force_str from django.utils.html import strip_tags @@ -5,7 +7,6 @@ from django.utils.translation import gettext_lazy as _ from cms.models import CMSPlugin -from cms.utils.copy_plugins import copy_plugins_to from . import settings from .html import clean_html, extract_images @@ -82,14 +83,22 @@ def clean_plugins(self): def copy_referenced_plugins(self): referenced_plugins = self.get_referenced_plugins() if referenced_plugins: - plugins_pairs = list(copy_plugins_to( - referenced_plugins, - self.placeholder, - to_language=self.language, - parent_plugin_id=self.id, - )) - self.add_existing_child_plugins_to_pairs(plugins_pairs) - self.post_copy(self, plugins_pairs) + plugin_pairs = [] + for source_plugin in referenced_plugins: + new_plugin = deepcopy(source_plugin) + new_plugin.pk = None + new_plugin.id = None + new_plugin._state.adding = True + new_plugin.parent = self + if hasattr(self.placeholder, "add_plugin"): # CMS v4 + new_plugin.position = self.position + 1 + new_plugin = self.placeholder.add_plugin(new_plugin) + else: + new_plugin = self.add_child(instance=new_plugin) + new_plugin.copy_relations(source_plugin) + plugin_pairs.append((new_plugin, source_plugin)) + self.add_existing_child_plugins_to_pairs(plugin_pairs) + self.post_copy(self, plugin_pairs) def get_referenced_plugins(self): ids_in_body = set(plugin_tags_to_id_list(self.body)) diff --git a/djangocms_text_ckeditor/static/djangocms_text_ckeditor/ckeditor_plugins/cmsplugins/plugin.js b/djangocms_text_ckeditor/static/djangocms_text_ckeditor/ckeditor_plugins/cmsplugins/plugin.js index e84d80cd4..d6366dd0b 100644 --- a/djangocms_text_ckeditor/static/djangocms_text_ckeditor/ckeditor_plugins/cmsplugins/plugin.js +++ b/djangocms_text_ckeditor/static/djangocms_text_ckeditor/ckeditor_plugins/cmsplugins/plugin.js @@ -63,10 +63,23 @@ init: function (editor) { var that = this; + CKEDITOR.on('instanceReady', function () { + var widgetInstances = []; + + for (var key in editor.widgets.instances) { + if (editor.widgets.instances.hasOwnProperty(key)) { + widgetInstances.push(editor.widgets.instances[key]); + } + } + + that.numberOfChildren = CKEDITOR.tools.array.filter(widgetInstances, function (i) { + return i.name === 'cms-widget'; + }).length; + }); /** * populated with _fresh_ child plugins */ - this.child_plugins = []; + this.unsaved_child_plugins = []; var settings = CMS.CKEditor.editors[editor.id].settings; this.setupCancelCleanupCallback(settings); @@ -210,9 +223,10 @@ // in case it's a fresh text plugin children don't have to be // deleted separately if (!editor.config.settings.delete_on_cancel && addedChildPlugin) { - that.child_plugins.push(data.plugin_id); + that.unsaved_child_plugins.push(data.plugin_id); } that.insertPlugin(data, dialog.sender._.editor); + that.numberOfChildren += 1 CMS.API.Helpers.onPluginSave = onSave; return false; @@ -315,6 +329,7 @@ plugin_type: item.attr('rel'), plugin_parent: settings.plugin_id, plugin_language: settings.plugin_language, + plugin_position: settings.plugin_position + 1 + this.numberOfChildren, cms_path: window.parent.location.pathname, cms_history: 0 }; @@ -391,10 +406,10 @@ var that = this; var CMS = window.parent.CMS; var cancelModalCallback = function cancelModalCallback(e, opts) { - if (!settings.delete_on_cancel && !that.child_plugins.length) { + if (!settings.delete_on_cancel && !that.unsaved_child_plugins.length) { return; } - if (that.child_plugins.length) { + if (that.unsaved_child_plugins.length) { e.preventDefault(); CMS.API.Toolbar.showLoader(); var data = { @@ -402,7 +417,7 @@ }; if (!settings.delete_on_cancel) { - data.child_plugins = that.child_plugins; + data.child_plugins = that.unsaved_child_plugins; } $.ajax({ diff --git a/djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-5f73f48756.cms.ckeditor.min.js b/djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-ab7ff62c5a.cms.ckeditor.min.js similarity index 98% rename from djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-5f73f48756.cms.ckeditor.min.js rename to djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-ab7ff62c5a.cms.ckeditor.min.js index 958988937..b9e904b24 100644 --- a/djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-5f73f48756.cms.ckeditor.min.js +++ b/djangocms_text_ckeditor/static/djangocms_text_ckeditor/js/dist/bundle-ab7ff62c5a.cms.ckeditor.min.js @@ -1401,6 +1401,6 @@ CKEDITOR.env.webkit&&e.push("float:none;"),e.push('"'),e.push('align="',CKEDITOR * For licensing, see LICENSE.md or https://ckeditor.com/legal/ckeditor-oss-license */ CKEDITOR.plugins.add("cmsresize",{init:function(e){function i(i){var t=i.originalEvent.screenX-s.x,r=i.originalEvent.screenY-s.y,a=d.width,c=d.height,l=a+t*("rtl"==o?-1:1),z=c+r;m&&(a=Math.max(n.resize_minWidth,Math.min(l,n.resize_maxWidth))),h&&(c=Math.max(n.resize_minHeight,Math.min(z,n.resize_maxHeight))),e.resize(m?a:null,c)}function t(){CMS.$(CKEDITOR.document.$).off("pointermove",i),CMS.$(CKEDITOR.document.$).off("pointerup",t),e.document&&(CMS.$(e.document.$).off("pointermove",i),CMS.$(e.document.$).off("pointerup",t))}var n=e.config,r=e.ui.spaceId("resizer"),o=e.element?e.element.getDirection(1):"ltr";if(!n.resize_dir&&(n.resize_dir="vertical"),void 0===n.resize_maxWidth&&(n.resize_maxWidth=3e3),void 0===n.resize_maxHeight&&(n.resize_maxHeight=3e3),void 0===n.resize_minWidth&&(n.resize_minWidth=750),void 0===n.resize_minHeight&&(n.resize_minHeight=250),!1!==n.resize_enabled){var s,d,a=null,m=("both"==n.resize_dir||"horizontal"==n.resize_dir)&&n.resize_minWidth!=n.resize_maxWidth,h=("both"==n.resize_dir||"vertical"==n.resize_dir)&&n.resize_minHeight!=n.resize_maxHeight,c=CKEDITOR.tools.addFunction(function(r){a||(a=e.getResizable()),d={width:a.$.offsetWidth||0,height:a.$.offsetHeight||0},s={x:r.screenX,y:r.screenY},n.resize_minWidth>d.width&&(n.resize_minWidth=d.width),n.resize_minHeight>d.height&&(n.resize_minHeight=d.height),CMS.$(CKEDITOR.document.$).on("pointermove",i),CMS.$(CKEDITOR.document.$).on("pointerup",t),e.document&&(CMS.$(e.document.$).on("pointermove",i),CMS.$(e.document.$).on("pointerup",t)),r.preventDefault&&r.preventDefault()});CMS.$(CKEDITOR.document.$).find("html").attr("data-touch-action","none"),e.on("destroy",function(){CKEDITOR.tools.removeFunction(c)}),e.on("uiSpace",function(i){if("bottom"==i.data.space){var t="";m&&!h&&(t=" cke_resizer_horizontal"),!m&&h&&(t=" cke_resizer_vertical");var n=''+("ltr"==o?"◢":"◣")+"";"ltr"==o&&"ltr"==t?i.data.html+=n:i.data.html=n+i.data.html}},e,null,100),e.on("maximize",function(i){e.ui.space("resizer")[i.data==CKEDITOR.TRISTATE_ON?"hide":"show"]()})}}})}(CMS.$); -!function(e){function t(e){var t=(e.match(/<\s*([^>\s]+)[\s\S]*?>/)||[0,!1]).splice(1),n=t.some(function(e){return e&&CKEDITOR.dtd.$block[e]}),i="span";return n&&(i="div"),i}function n(t,n){t.each(function(t,i){var l,a=e(i);l=e("<"+n+">"),e.each(i.attributes,function(e,t){l.attr(t.nodeName,t.nodeValue)}),l.html(a.html()),a.replaceWith(l)})}CKEDITOR&&CKEDITOR.plugins&&CKEDITOR.plugins.registered&&CKEDITOR.plugins.registered.cmsplugins||CKEDITOR.plugins.add("cmsplugins",{icons:"cmsplugins",init:function(t){var n=this;this.child_plugins=[];var i=CMS.CKEditor.editors[t.id].settings;if(this.setupCancelCleanupCallback(i),void 0===i||void 0===i.plugins)return!1;this.setupDialog(t),t.ui.add("cmsplugins",CKEDITOR.UI_PANELBUTTON,{toolbar:"cms,0",label:i.lang.toolbar,title:i.lang.toolbar,className:"cke_panelbutton__cmsplugins",modes:{wysiwyg:1},editorFocus:0,panel:{css:[CKEDITOR.skin.getPath("editor")].concat(t.config.contentsCss),attributes:{role:"cmsplugins","aria-label":i.lang.aria}},onBlock:function(i,l){l.element.setHtml(t.plugins.cmsplugins.setupDropdown(t)),e(l.element.$).find(".cke_panel_listItem a").bind("click",function(l){l.preventDefault(),n.addPlugin(e(this),i,t)})}}),t.contextMenu&&this.setupContextMenu(t),t.addCommand("cmspluginsEdit",{exec:function(){var e=n.getElementFromSelection(t),i=n.getPluginWidget(e);i&&n.editPlugin(i,t)}});var l=function(n){if(n.stop(),"touchend"===n.type||"click"===n.type){var i,l=e(n.currentTarget).closest("cms-plugin")[0];i=new CKEDITOR.dom.element(l).getParent(),n.data=n.data||{},t.getSelection().fake(i)}t.execCommand("cmspluginsEdit")};t.on("doubleclick",l),t.on("instanceReady",function(){}),this.setupDataProcessor(t)},getElementFromSelection:function(e){var t=e.getSelection();return t.getSelectedElement()||t.getCommonAncestor().getAscendant("cms-plugin",!0)},getPluginWidget:function(e){return e?e.getAscendant("cms-plugin",!0)||e.findOne("cms-plugin"):null},setupDialog:function(t){var n=this,i=function(){return{title:"",minWidth:200,minHeight:200,contents:[{elements:[{type:"html",html:'