diff --git a/README.rst b/README.rst index 21d1a295..3ee7ccab 100644 --- a/README.rst +++ b/README.rst @@ -295,6 +295,25 @@ rendering the above settings useless. To completely disable the feature, set ``TEXT_HTML_SANITIZE = False``. +Usage outside django CMS +------------------------ + +django CMS Text can be used without django CMS installed. Without django CMS it +offers the ``HTMLField``, ``HTMLFormField``, and the ``TextEditorWidget`` class +which can be used by any Django model or form. + +If django CMS is not installed with django CMS Text, add the following to your +``MIGRATION_MODULES`` setting:: + + MIGRATION_MODULES = { + ..., + "djangocms_text": None, + ... + } + +This will prevent the creation of the model for the django CMS text plugin. + + Development =========== diff --git a/djangocms_text/__init__.py b/djangocms_text/__init__.py index f21cc421..aa336d5c 100644 --- a/djangocms_text/__init__.py +++ b/djangocms_text/__init__.py @@ -16,4 +16,4 @@ 10. Github actions will publish the new package to pypi """ -__version__ = "0.3.3" +__version__ = "0.4.0" diff --git a/djangocms_text/apps.py b/djangocms_text/apps.py index 10a4a6cd..46670940 100644 --- a/djangocms_text/apps.py +++ b/djangocms_text/apps.py @@ -1,5 +1,5 @@ -from django.apps import AppConfig -from django.core.checks import Warning, register +from django.apps import AppConfig, apps +from django.core.checks import Error, Warning, register class TextConfig(AppConfig): @@ -10,6 +10,7 @@ class TextConfig(AppConfig): def ready(self): self.inline_models = discover_inline_editable_models() register(check_ckeditor_settings) + register(check_no_cms_config) def discover_inline_editable_models(): @@ -37,19 +38,21 @@ def discover_inline_editable_models(): field_instance.__class__.__name__ ) - from cms.plugin_pool import plugin_pool + if apps.is_installed("cms"): + # also check the plugins + from cms.plugin_pool import plugin_pool - for plugin in plugin_pool.plugins.values(): - model = plugin.model - if model._meta.app_label in blacklist_apps: - continue - for field_name in getattr(plugin, "frontend_editable_fields", []): - form = plugin.form - field_instance = form.base_fields.get(field_name, None) - if field_instance.__class__.__name__ in registered_inline_fields: - inline_models[f"{model._meta.app_label}-{model._meta.model_name}-{field_name}"] = ( - field_instance.__class__.__name__ - ) + for plugin in plugin_pool.plugins.values(): + model = plugin.model + if model._meta.app_label in blacklist_apps: + continue + for field_name in getattr(plugin, "frontend_editable_fields", []): + form = plugin.form + field_instance = form.base_fields.get(field_name, None) + if field_instance.__class__.__name__ in registered_inline_fields: + inline_models[f"{model._meta.app_label}-{model._meta.model_name}-{field_name}"] = ( + field_instance.__class__.__name__ + ) return inline_models @@ -84,3 +87,23 @@ def check_ckeditor_settings(app_configs, **kwargs): # pragma: no cover ) return warnings + + +def check_no_cms_config(app_configs, **kwargs): # pragma: no cover + from django.conf import settings + + if "cms" in settings.INSTALLED_APPS: + return [] + + migration_modules = getattr(settings, "MIGRATION_MODULES", {}) + if "djangocms_text" in migration_modules and migration_modules["djangocms_text"] is None: + return [] + + return [ + Error( + "When using djangocms-text outside django-cms, deactivate migrations for it. Migrations are only " + "needed when using djangocms-text within django-cms. They will fail otherwise.", + hint="Add \"'djangocms_text': None\" to your MIGRATION_MODULES setting.", + id="djangocms_text.E001", + ) + ] diff --git a/djangocms_text/cms_toolbars.py b/djangocms_text/cms_toolbars.py index 201690c7..c6e8029f 100644 --- a/djangocms_text/cms_toolbars.py +++ b/djangocms_text/cms_toolbars.py @@ -7,13 +7,13 @@ from django.utils.functional import cached_property from django.utils.translation import gettext_lazy as _ -from cms.cms_toolbars import CMSToolbar from cms.toolbar.items import Button, ButtonList, TemplateItem +from cms.toolbar_base import CMSToolbar from cms.toolbar_pool import toolbar_pool from . import settings -from .utils import get_cancel_url, get_messages_url, get_render_plugin_url, get_url_endpoint -from .widgets import TextEditorWidget, rte_config +from .utils import get_cancel_url, get_messages_url, get_render_plugin_url +from .widgets import TextEditorWidget, get_url_endpoint, rte_config class IconButton(Button): diff --git a/djangocms_text/fields.py b/djangocms_text/fields.py index f336430b..a7fa5df5 100644 --- a/djangocms_text/fields.py +++ b/djangocms_text/fields.py @@ -4,7 +4,6 @@ from django.utils.safestring import mark_safe from .html import clean_html, render_dynamic_attributes -from .utils import get_url_endpoint from .widgets import TextEditorWidget @@ -66,7 +65,7 @@ def formfield(self, **kwargs): # override the admin widget if defaults["widget"] == admin_widgets.AdminTextareaWidget: # In the admin the URL endpoint is available - defaults["widget"] = TextEditorWidget(configuration=self.configuration, url_endpoint=get_url_endpoint()) + defaults["widget"] = TextEditorWidget(configuration=self.configuration) return super().formfield(**defaults) def clean(self, value, model_instance): diff --git a/djangocms_text/models.py b/djangocms_text/models.py index 8ea9d8de..f1980615 100644 --- a/djangocms_text/models.py +++ b/djangocms_text/models.py @@ -1,162 +1,155 @@ from copy import deepcopy +from django.apps import apps from django.db import models from django.utils.encoding import force_str from django.utils.html import strip_tags from django.utils.text import Truncator from django.utils.translation import gettext_lazy as _ -from cms.models import CMSPlugin - -from . import settings -from .html import clean_html, extract_images -from .utils import plugin_tags_to_db, plugin_tags_to_id_list, plugin_to_tag, replace_plugin_tags - - -try: - from softhyphen.html import hyphenate -except ImportError: - - def hyphenate(t): - return t - - -class AbstractText(CMSPlugin): - """ - Abstract Text Plugin Class designed to be backwards compatible with - djangocms-text-ckeditor: - - 1. If the json field is empty, the editor reads text from the body field. - 2. When saving, the editor writes to the body field and the json field. It also sets - the rte field with a unique label identifying the json dialect used to represent - the rich text. - 3. If the rte field is not known to the frontend editor, the plugin is read-only. - 4. if the rte field is known to the frontend editor, it takes precedence over the - body field. - - djangocms-text-ckeditor Text fields are migrated by copying the body field only. - """ - - # Add an app namespace to related_name to avoid field name clashes - # with any other plugins that have a field with the same name as the - # lowercase of the class name of this model. - # https://github.com/divio/django-cms/issues/5030 - cmsplugin_ptr = models.OneToOneField( - CMSPlugin, - on_delete=models.CASCADE, - related_name="%(app_label)s_%(class)s", - parent_link=True, - ) - body = models.TextField(_("body")) - json = models.JSONField(_("json"), blank=True, null=True) - rte = models.CharField( - default="", - blank=True, - max_length=16, - help_text="The rich text editor used to create this text. JSON formats vary between editors.", - ) - - search_fields = ("body",) - - class Meta: - abstract = True - - def __str__(self): - return Truncator(strip_tags(self.body).replace("­", "")).words(3, truncate="...") - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.body = force_str(self.body) - - def clean(self): - self.body = plugin_tags_to_db(self.body) - - def save(self, *args, **kwargs): - super().save(*args, **kwargs) - body = self.body - body = extract_images(body, self) - body = clean_html(body, full=False) - if settings.TEXT_AUTO_HYPHENATE: - try: - body = hyphenate(body, language=self.language) - except (TypeError, CMSPlugin.DoesNotExist): - body = hyphenate(body) - self.body = body - # no need to pass args or kwargs here - # this 2nd save() call is internal and should be - # fully managed by us. - # think of it as an update() vs save() - super().save(update_fields=("body",)) - - def clean_plugins(self): - ids = self._get_inline_plugin_ids() - unbound_plugins = self.cmsplugin_set.exclude(pk__in=ids) - - for plugin in unbound_plugins: - # delete plugins that are not referenced in the text anymore - plugin.delete() - - def copy_referenced_plugins(self): - referenced_plugins = self.get_referenced_plugins() - if referenced_plugins: - 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)) - child_plugins_ids = set(self.cmsplugin_set.all().values_list("id", flat=True)) - referenced_plugins_ids = ids_in_body - child_plugins_ids - return CMSPlugin.objects.filter(id__in=referenced_plugins_ids) - - def add_existing_child_plugins_to_pairs(self, plugins_pairs): - for plugin in self.cmsplugin_set.all(): - plugins_pairs.append((plugin, plugin)) - - def _get_inline_plugin_ids(self): - return plugin_tags_to_id_list(self.body) - - def post_copy(self, old_instance, ziplist): - """ - Fix references to plugins - """ - replace_ids = {} - for new, old in ziplist: - replace_ids[old.pk] = new.pk +if apps.is_installed("cms"): + from cms.models import CMSPlugin + + from . import settings + from .html import clean_html, extract_images + from .utils import plugin_tags_to_db, plugin_tags_to_id_list, plugin_to_tag, replace_plugin_tags - old_text = old_instance.get_plugin_instance()[0].body - self.body = replace_plugin_tags(old_text, replace_ids) - self.save() + try: + from softhyphen.html import hyphenate + except ImportError: - def notify_on_autoadd_children(self, request, conf, children): + def hyphenate(t): + return t + + class AbstractText(CMSPlugin): """ - Method called when we auto add children to this plugin via - default_plugins//children in CMS_PLACEHOLDER_CONF. - we must replace some strings with child tag for the editor. - Strings are "%(_tag_child_)s" with the inserted order of children + Abstract Text Plugin Class designed to be backwards compatible with + djangocms-text-ckeditor: + + 1. If the json field is empty, the editor reads text from the body field. + 2. When saving, the editor writes to the body field and the json field. It also sets + the rte field with a unique label identifying the json dialect used to represent + the rich text. + 3. If the rte field is not known to the frontend editor, the plugin is read-only. + 4. if the rte field is known to the frontend editor, it takes precedence over the + body field. + + djangocms-text-ckeditor Text fields are migrated by copying the body field only. """ - replacements = {} - order = 1 - for child in children: - replacements["_tag_child_" + str(order)] = plugin_to_tag(child) - order += 1 - self.body = self.body % replacements - self.save() - - -class Text(AbstractText): - class Meta: - abstract = False + + # Add an app namespace to related_name to avoid field name clashes + # with any other plugins that have a field with the same name as the + # lowercase of the class name of this model. + # https://github.com/divio/django-cms/issues/5030 + cmsplugin_ptr = models.OneToOneField( + CMSPlugin, + on_delete=models.CASCADE, + related_name="%(app_label)s_%(class)s", + parent_link=True, + ) + body = models.TextField(_("body")) + json = models.JSONField(_("json"), blank=True, null=True) + rte = models.CharField( + default="", + blank=True, + max_length=16, + help_text="The rich text editor used to create this text. JSON formats vary between editors.", + ) + + search_fields = ("body",) + + class Meta: + abstract = True + + def __str__(self): + return Truncator(strip_tags(self.body).replace("­", "")).words(3, truncate="...") + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.body = force_str(self.body) + + def clean(self): + self.body = plugin_tags_to_db(self.body) + + def save(self, *args, **kwargs): + super().save(*args, **kwargs) + body = self.body + body = extract_images(body, self) + body = clean_html(body, full=False) + if settings.TEXT_AUTO_HYPHENATE: + try: + body = hyphenate(body, language=self.language) + except (TypeError, CMSPlugin.DoesNotExist): + body = hyphenate(body) + self.body = body + # no need to pass args or kwargs here + # this 2nd save() call is internal and should be + # fully managed by us. + # think of it as an update() vs save() + super().save(update_fields=("body",)) + + def clean_plugins(self): + ids = self._get_inline_plugin_ids() + unbound_plugins = self.cmsplugin_set.exclude(pk__in=ids) + + for plugin in unbound_plugins: + # delete plugins that are not referenced in the text anymore + plugin.delete() + + def copy_referenced_plugins(self): + if referenced_plugins := self.get_referenced_plugins(): + 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)) + child_plugins_ids = set(self.cmsplugin_set.all().values_list("id", flat=True)) + referenced_plugins_ids = ids_in_body - child_plugins_ids + return CMSPlugin.objects.filter(id__in=referenced_plugins_ids) + + def add_existing_child_plugins_to_pairs(self, plugins_pairs): + for plugin in self.cmsplugin_set.all(): + plugins_pairs.append((plugin, plugin)) + + def _get_inline_plugin_ids(self): + return plugin_tags_to_id_list(self.body) + + def post_copy(self, old_instance, ziplist): + """ + Fix references to plugins + """ + replace_ids = {old.pk: new.pk for new, old in ziplist} + old_text = old_instance.get_plugin_instance()[0].body + self.body = replace_plugin_tags(old_text, replace_ids) + self.save() + + 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. + we must replace some strings with child tag for the editor. + Strings are "%(_tag_child_)s" with the inserted order of children + """ + replacements = { + f"_tag_child_{str(order)}": plugin_to_tag(child) for order, child in enumerate(children, start=1) + } + self.body = self.body % replacements + self.save() + + class Text(AbstractText): + class Meta: + abstract = False diff --git a/djangocms_text/utils.py b/djangocms_text/utils.py index 972e15c0..bb402d65 100644 --- a/djangocms_text/utils.py +++ b/djangocms_text/utils.py @@ -1,6 +1,6 @@ import re from collections import OrderedDict -from functools import WRAPPER_ASSIGNMENTS, cache, wraps +from functools import WRAPPER_ASSIGNMENTS, wraps from django.template.defaultfilters import force_escape from django.template.loader import render_to_string @@ -167,17 +167,6 @@ def get_plugins_from_text(text, regex=OBJ_ADMIN_RE): return {plugin.pk: plugin for plugin in plugin_list} -@cache -def get_url_endpoint(): - """Get the url for dynamic liks for cms plugins and HTMLFields""" - from django.contrib.admin import site - - for model_admin in site._registry.values(): - if hasattr(model_admin, "global_link_url_name"): - return admin_reverse(model_admin.global_link_url_name) - return admin_reverse("djangocms_text_textplugin_get_available_urls") - - def get_render_plugin_url(): """Get the url for rendering a text-enabled plugin for the toolbar""" return admin_reverse("djangocms_text_textplugin_render_plugin") diff --git a/djangocms_text/widgets.py b/djangocms_text/widgets.py index ab40b46b..066a9ef9 100644 --- a/djangocms_text/widgets.py +++ b/djangocms_text/widgets.py @@ -1,6 +1,7 @@ import json import uuid from copy import deepcopy +from functools import cache from itertools import groupby from typing import Union @@ -9,6 +10,7 @@ from django.core.serializers.json import DjangoJSONEncoder from django.db import models from django.template.loader import render_to_string +from django.urls.exceptions import NoReverseMatch from django.utils.safestring import mark_safe from django.utils.translation.trans_real import get_language, gettext @@ -16,13 +18,27 @@ from . import settings as text_settings from .editors import DEFAULT_TOOLBAR_CMS, DEFAULT_TOOLBAR_HTMLField, get_editor_base_config, get_editor_config -from .utils import cms_placeholder_add_plugin, get_url_endpoint +from .utils import cms_placeholder_add_plugin rte_config = get_editor_config() #: The configuration for the text editor widget +@cache +def get_url_endpoint(): + """Get the url for dynamic liks for cms plugins and HTMLFields""" + from django.contrib.admin import site + + for model_admin in site._registry.values(): + if hasattr(model_admin, "global_link_url_name"): + return admin_reverse(model_admin.global_link_url_name) + try: + return admin_reverse("djangocms_text_textplugin_get_available_urls") + except NoReverseMatch: + return None + + class TextEditorWidget(forms.Textarea): class Media: css = { diff --git a/pyproject.toml b/pyproject.toml index b915df43..c5e78cf2 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,6 @@ classifiers = [ ] dynamic = [ "version" ] dependencies = [ - "django-cms>=3.11", "lxml", "nh3", "packaging",