import json
import django
from django import forms
from django.conf import settings
from django.core.checks import Warning
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.templatetags.static import static
from django.utils.functional import lazy
from django.utils.html import strip_tags
from django.utils.text import Truncator
from html_sanitizer.django import get_sanitizer
from js_asset import JS
__all__ = ["InlineCKEditorField"]
CKEDITOR_JS_URL = JS(
"https://cdn.ckeditor.com/4.22.1/full-all/ckeditor.js",
{
# "integrity": "sha384-qdzSU+GzmtYP2hzdmYowu+mz86DPHVROVcDAPdT/ePp1E8ke2z0gy7ITERtHzPmJ",
"crossorigin": "anonymous",
"defer": "defer",
},
)
CKEDITOR_CONFIG = {
"default": {
"format_tags": "h1;h2;h3;p",
"toolbar": "Custom",
"toolbar_Custom": [
[
"Format",
"RemoveFormat",
"-",
"Bold",
"Italic",
"Subscript",
"Superscript",
"-",
"BulletedList",
"NumberedList",
"-",
"Link",
"Unlink",
"Anchor",
"-",
"HorizontalRule",
"-",
"Source",
],
],
"extraPlugins": ["autogrow"],
"height": 100,
"autoGrow_minHeight": 30,
"autoGrow_maxHeight": 0,
"autoGrow_bottomSpace": 0,
"removePlugins": ["elementspath"],
"resize_enabled": False,
"contentsCss": lazy(
lambda: static("feincms3/inline-ckeditor-contents.css"), str
)(),
"versionCheck": False,
}
}
def _url():
return getattr(settings, "FEINCMS3_CKEDITOR_URL", CKEDITOR_JS_URL)
def _config():
return CKEDITOR_CONFIG | getattr(settings, "FEINCMS3_CKEDITOR_CONFIG", {})
[docs]
class InlineCKEditorField(models.TextField):
"""
This field uses an inline CKEditor 4 instance to edit HTML. All HTML is
cleansed using `html-sanitizer
<https://github.com/matthiask/html-sanitizer>`__.
The default configuration of both ``InlineCKEditorField`` and HTML
sanitizer only allows a heavily restricted subset of HTML. This should make
it easier to write CSS which works for all possible combinations of content
which can be added through Django's administration interface.
The field supports the following keyword arguments to alter its
configuration and behavior:
- ``cleanse``: A callable which gets messy HTML and returns cleansed HTML.
- ``ckeditor``: A CDN URL for CKEditor 4.
- ``config``: Change the CKEditor 4 configuration. See the source for the
current default.
- ``config_name``: Alternative way of configuring the CKEditor. Uses the
``FEINCMS3_CKEDITOR_CONFIG`` setting.
"""
def __init__(self, *args, **kwargs):
self.cleanse = kwargs.pop("cleanse", None) or get_sanitizer().sanitize
kwargs = self._extract_widget_config(kwargs)
super().__init__(*args, **kwargs)
def check(self, **kwargs):
errors = super().check(**kwargs)
errors.append(
Warning(
"The InlineCKEditorField uses the insecure CKEditor 4 non-LTS version.",
id="feincms3.W007",
)
)
return errors
def _extract_widget_config(self, kwargs):
if "config_name" in kwargs:
self.widget_config = {
"ckeditor": kwargs.pop("ckeditor", None),
"config": _config()[kwargs.pop("config_name")],
}
else:
self.widget_config = {
"ckeditor": kwargs.pop("ckeditor", None),
"config": kwargs.pop("config", None),
}
return kwargs
[docs]
def clean(self, value, instance):
"""Run the cleaned form value through the ``cleanse`` callable"""
return self.cleanse(super().clean(value, instance))
[docs]
def deconstruct(self):
"""Act as if we were a ``models.TextField``. Migrations do not have
to know that's not 100% true."""
name, path, args, kwargs = super().deconstruct()
return (name, "django.db.models.TextField", args, kwargs)
[docs]
def contribute_to_class(self, cls, name, **kwargs):
"""Add a ``get_*_excerpt`` method to models which returns a
de-HTML-ified excerpt of the contents of this field"""
super().contribute_to_class(cls, name, **kwargs)
setattr(
cls,
f"get_{name}_excerpt",
lambda self, words=10, truncate=" ...": Truncator(
strip_tags(getattr(self, name))
).words(words, truncate=truncate),
)
class InlineCKEditorWidget(forms.Textarea):
def __init__(self, *args, **kwargs):
self.ckeditor = kwargs.pop("ckeditor") or _url()
self.config = kwargs.pop("config") or _config()["default"]
self.config["versionCheck"] = False
attrs = kwargs.setdefault("attrs", {})
attrs["data-inline-cke"] = id(self.config)
if django.VERSION < (4, 2):
attrs["data-inline-cke-dj41"] = True
super().__init__(*args, **kwargs)
@property
def media(self):
return forms.Media(
css={"all": ["feincms3/inline-ckeditor.css"]},
js=[
self.ckeditor,
JS(
"feincms3/inline-ckeditor.js",
{
"data-inline-cke-id": id(self.config),
"data-inline-cke-config": json.dumps(
self.config, cls=DjangoJSONEncoder
),
"defer": "defer",
},
),
],
)