Django База [2023]: Добавляем редактор CKEditor 5 в Django 4.1 ✍️ #35
Django

Django База [2023]: Добавляем редактор CKEditor 5 в Django 4.1 ✍️ #35

Теги не заданы
Razilator

В этой статье мы установим CKEditor 5. Он предоставляет богатый набор функций для редактирования текста, таких как форматирование, вставка изображений, ссылок и многое другое в Django.

Установка django-ckeditor-5

CKEditor устанавливаем с помощью слудующей команды в терминале: pip install django-ckeditor-5.

Результат установки:

Терминал
(venv) PS C:\Users\Razilator\Desktop\Base\backend> pip install django-ckeditor-5
Collecting django-ckeditor-5
  Downloading django_ckeditor_5-0.2.4-py3-none-any.whl (1.9 MB)
     ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ 1.9/1.9 MB 4.7 MB/s eta 0:00:00
Requirement already satisfied: Django>=2.2 in c:\users\razilator\desktop\base\venv\lib\site-packages (from django-ckeditor-5) (4.1.5)
Requirement already satisfied: Pillow in c:\users\razilator\desktop\base\venv\lib\site-packages (from django-ckeditor-5) (9.4.0)
Requirement already satisfied: asgiref<4,>=3.5.2 in c:\users\razilator\desktop\base\venv\lib\site-packages (from Django>=2.2->django-ckeditor-5) (3.6.0)
Requirement already satisfied: sqlparse>=0.2.2 in c:\users\razilator\desktop\base\venv\lib\site-packages (from Django>=2.2->django-ckeditor-5) (0.4.3)
Requirement already satisfied: tzdata in c:\users\razilator\desktop\base\venv\lib\site-packages (from Django>=2.2->django-ckeditor-5) (2022.7)
Installing collected packages: django-ckeditor-5
Successfully installed django-ckeditor-5-0.2.4

[notice] A new release of pip available: 22.3 -> 23.0.1
[notice] To update, run: python.exe -m pip install --upgrade pip

После установки добавляем в конфигурационный файл settings.py наше приложение:

backend/settings.py
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sites',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'modules.blog.apps.BlogConfig',
    'modules.system.apps.SystemConfig',
    'mptt',
    'debug_toolbar',
    'taggit',
    'captcha',
    'django_ckeditor_5',
]

Примечание: у нас также должны правильно установлены настройки для media и статических файлов, делали мы это в этом уроке.

Добавление конфигурации CKEditor 5

Далее в наш конфигурационный файл необходимо добавить настройки для CKEditor, в конец файла settings.py добавляем настройки по умолчанию:

backend/settings.py
customColorPalette = [
        {
            'color': 'hsl(4, 90%, 58%)',
            'label': 'Red'
        },
        {
            'color': 'hsl(340, 82%, 52%)',
            'label': 'Pink'
        },
        {
            'color': 'hsl(291, 64%, 42%)',
            'label': 'Purple'
        },
        {
            'color': 'hsl(262, 52%, 47%)',
            'label': 'Deep Purple'
        },
        {
            'color': 'hsl(231, 48%, 48%)',
            'label': 'Indigo'
        },
        {
            'color': 'hsl(207, 90%, 54%)',
            'label': 'Blue'
        },
    ]

  CKEDITOR_5_CUSTOM_CSS = 'path_to.css' # optional
  CKEDITOR_5_FILE_STORAGE = "path_to_storage.CustomStorage" # optional
  CKEDITOR_5_CONFIGS = {
    'default': {
        'toolbar': ['heading', '|', 'bold', 'italic', 'link',
                    'bulletedList', 'numberedList', 'blockQuote', 'imageUpload', ],

    },
    'extends': {
        'blockToolbar': [
            'paragraph', 'heading1', 'heading2', 'heading3',
            '|',
            'bulletedList', 'numberedList',
            '|',
            'blockQuote',
        ],
        'toolbar': ['heading', '|', 'outdent', 'indent', '|', 'bold', 'italic', 'link', 'underline', 'strikethrough',
        'code','subscript', 'superscript', 'highlight', '|', 'codeBlock', 'sourceEditing', 'insertImage',
                    'bulletedList', 'numberedList', 'todoList', '|',  'blockQuote', 'imageUpload', '|',
                    'fontSize', 'fontFamily', 'fontColor', 'fontBackgroundColor', 'mediaEmbed', 'removeFormat',
                    'insertTable',],
        'image': {
            'toolbar': ['imageTextAlternative', '|', 'imageStyle:alignLeft',
                        'imageStyle:alignRight', 'imageStyle:alignCenter', 'imageStyle:side',  '|'],
            'styles': [
                'full',
                'side',
                'alignLeft',
                'alignRight',
                'alignCenter',
            ]

        },
        'table': {
            'contentToolbar': [ 'tableColumn', 'tableRow', 'mergeTableCells',
            'tableProperties', 'tableCellProperties' ],
            'tableProperties': {
                'borderColors': customColorPalette,
                'backgroundColors': customColorPalette
            },
            'tableCellProperties': {
                'borderColors': customColorPalette,
                'backgroundColors': customColorPalette
            }
        },
        'heading' : {
            'options': [
                { 'model': 'paragraph', 'title': 'Paragraph', 'class': 'ck-heading_paragraph' },
                { 'model': 'heading1', 'view': 'h1', 'title': 'Heading 1', 'class': 'ck-heading_heading1' },
                { 'model': 'heading2', 'view': 'h2', 'title': 'Heading 2', 'class': 'ck-heading_heading2' },
                { 'model': 'heading3', 'view': 'h3', 'title': 'Heading 3', 'class': 'ck-heading_heading3' }
            ]
        }
    },
    'list': {
        'properties': {
            'styles': 'true',
            'startIndex': 'true',
            'reversed': 'true',
        }
    }
}

Обработка CKEditor 5 в urls.py

Далее нам нужно обработать представления CKEditor, в главном файле urls.py добавьте следующую строку подключения:

backend/urls.py
"""backend URL Configuration

The `urlpatterns` list routes URLs to views. For more information please see:
    https://docs.djangoproject.com/en/4.1/topics/http/urls/
Examples:
Function views
    1. Add an import:  from my_app import views
    2. Add a URL to urlpatterns:  path('', views.home, name='home')
Class-based views
    1. Add an import:  from other_app.views import Home
    2. Add a URL to urlpatterns:  path('', Home.as_view(), name='home')
Including another URLconf
    1. Import the include() function: from django.urls import include, path
    2. Add a URL to urlpatterns:  path('blog/', include('blog.urls'))
"""
from django.contrib import admin
from django.urls import path, include
from django.conf.urls.static import static
from django.conf import settings

urlpatterns = [
    path('ckeditor5/', include('django_ckeditor_5.urls')),
    path('admin/', admin.site.urls),
    path('', include('modules.blog.urls')),
    path('', include('modules.system.urls')),
]

if settings.DEBUG:
    urlpatterns = [path('__debug__/', include('debug_toolbar.urls'))] + urlpatterns
    urlpatterns += static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT)

Изменение модели Article для работы редактора

Теперь переходим к модели Article, нам необходимо изменить TextField у полей full_description, short_description:

blog/models.py
from django.db import models
from django_ckeditor_5.fields import CKEditor5Field

class Article(models.Model):
    """
    Модель постов для сайта
    """
    
    # Другие поля и функции...
    
    short_description = CKEditor5Field(max_length=500, verbose_name='Краткое описание', config_name='extends')
    full_description = CKEditor5Field(verbose_name='Полное описание', config_name='extends')
    
    # Другие поля и функции...

Проведем миграции:

Терминал
(venv) PS C:\Users\Razilator\Desktop\Base\backend> py manage.py makemigrations
Migrations for 'blog':
  modules\blog\migrations\0005_alter_article_full_description_and_more.py
    - Alter field full_description on article
    - Alter field short_description on article
(venv) PS C:\Users\Razilator\Desktop\Base\backend> py manage.py migrate         
Operations to perform:
  Apply all migrations: admin, auth, blog, contenttypes, sessions, sites, system, taggit
Running migrations:
  Applying blog.0005_alter_article_full_description_and_more... OK

Давайте проверим редактор в админ-панеле:

CKEditor работает в админ-панеле
CKEditor работает в админ-панеле

Добаление редактора при добавлении и обновлении статей на сайте

Теперь необходимо обновить наши формы, для этого в файле forms.py нашего приложения blog проведем изменения:

blog/forms.py
from django import forms

from .models import Article, Comment


class ArticleCreateForm(forms.ModelForm):
    """
    Форма добавления статей на сайте
    """
    class Meta:
        model = Article
        fields = ('title', 'slug', 'category', 'short_description', 'full_description', 'thumbnail', 'status')

    def __init__(self, *args, **kwargs):
        """
        Обновление стилей формы под Bootstrap
        """
        super().__init__(*args, **kwargs)
        for field in self.fields:
            self.fields[field].widget.attrs.update({'class': 'form-control','autocomplete': 'off'})
        
        self.fields['short_description'].widget.attrs.update({'class': 'form-control django_ckeditor_5'})
        self.fields['full_description'].widget.attrs.update({'class': 'form-control django_ckeditor_5'})
        self.fields['short_description'].required = False
        self.fields['full_description'].required = False

class ArticleUpdateForm(ArticleCreateForm):
    """
    Форма обновления статьи на сайте
    """
    class Meta:
        model = Article
        fields = ArticleCreateForm.Meta.fields + ('updater', 'fixed')

    def __init__(self, *args, **kwargs):
        """
        Обновление стилей формы под Bootstrap
        """
        super().__init__(*args, **kwargs)

        self.fields['fixed'].widget.attrs.update({'class': 'form-check-input'})
        self.fields['short_description'].widget.attrs.update({'class': 'form-control django_ckeditor_5'})
        self.fields['full_description'].widget.attrs.update({'class': 'form-control django_ckeditor_5'})
        self.fields['short_description'].required = False
        self.fields['full_description'].required = False

Далее добавим {{ form.media }} в HTML разметку articles_create.html, articles_update.html:

templates/blog/articles_create.html
{% extends 'main.html' %}

{% block content %}
<div class="card mb-3 border-0 nth-shadow">
    <div class="card-body">
        <div class="card-title nth-card-title">
            <h4>Добавление статьи</h4>
        </div>
        <form method="post" action="{% url 'articles_create' %}" enctype="multipart/form-data">
            {% csrf_token %}
            {{ form.media }}
            {{ form.as_p }}
            <div class="d-grid gap-2 d-md-block mt-2">
                <button type="submit" class="btn btn-dark">Добавить статью</button>
            </div>
        </form>
    </div>
</div>
{% endblock %}
templates/blog/articles_update.html
{% extends 'main.html' %}

{% block content %}
<div class="card mb-3 border-0 nth-shadow">
    <div class="card-body">
        <div class="card-title nth-card-title">
            <h4>Обновление статьи: {{ article.title }}</h4>
        </div>
        <form method="post" enctype="multipart/form-data">
            {% csrf_token %}
            {{ form.media }}
            {{ form.as_p }}
            <div class="d-grid gap-2 d-md-block mt-2">
                <button type="submit" class="btn btn-dark">Обновить статью</button>
            </div>
        </form>
    </div>
</div>
{% endblock %}

Корректировка шаблонов полной и краткой статьи

Для правильного отображения контента применяемого от редактора, необходимо в файле articles_detail.html, а также в articles_list.html к полям {{ article.full_description }} и {{ article.short_description }} добавить тег safe:

templates/blog/articles_detail.html
{% extends 'main.html' %}
{% load mptt_tags %}
{% block content %}
<div class="card mb-3 border-0 shadow-sm">
	<div class="row">
		<div class="col-4">
			<img src="{{ article.thumbnail.url }}" class="card-img-top" alt="{{ article.title }}" />
		</div>
		<div class="col-8">
			<div class="card-body">
				<h5>{{ article.title }}</h5>
				<p class="card-text">{{ article.full_description|safe }}</p>
				Категория: <a href="{% url 'articles_by_category' article.category.slug %}">{{ article.category.title }}</a> / Добавил: {{ article.author.username }} / <small>{{ article.time_create }}</small>
			</div>
		</div>
	</div>
	{% if article.tags.all %}
	<div class="card-footer border-0">
		Теги записи: {% for tag in article.tags.all %} <a href="{% url 'articles_by_tags' tag.slug %}">{{ tag }}</a>, {% endfor %}
	</div>
	{% endif %}
</div>
<div class="card border-0">
	<div class="card-body">
		<h5 class="card-title">
			Комментарии
		</h5>
		{% include 'blog/comments/comments_list.html' %}
	</div>
</div>
{% endblock %}

{% block sidebar %}
<div class="card mb-2 border-0">
    <div class="card-body">
        <div class="card-title">
           Похожие статьи
        </div>
        <div class="card-text">
            <ul class="similar-articles">
                {% for sim_article in similar_articles %}
                    <li><a href="{{ sim_article.get_absolute_url }}">{{ sim_article.title }}</a></li>
                {% endfor %}
            </ul>
        </div>
    </div>
</div>
{% endblock %}

И соответственно в articles_list.html:

templates/blog/articles_list.html
{% extends 'main.html' %}

{% block content %}
    {% for article in articles %}
    <div class="card mb-3">
        <div class="row">
            <div class="col-4">
                <img src="{{ article.thumbnail.url }}" class="card-img-top" alt="{{ article.title }}">
            </div>
            <div class="col-8">
                <div class="card-body">
                    <h5 class="card-title"><a href="{{ article.get_absolute_url }}">{{ article.title }}</a></h5>
                    <p class="card-text">{{ article.short_description|safe }}</p>
                    </hr>
                    Категория: <a href="{% url 'articles_by_category' article.category.slug %}">{{ article.category.title }}</a> 
                    / Добавил: {{ article.author.username }}
                  </div>
                </div>
            </div>
      </div>
    {% endfor %}
{% endblock %}

Проверка редактора в деле

Добавление с сайта
Добавление с сайта
Обновление с сайта
Обновление с сайта
Добавляю статью с сайта
Добавляю статью с сайта
Статья успешно добавлена
Статья успешно добавлена

Дополнительно (может быть важно)

По умолчанию CKEditor сохраняет изображения в папку media. Выглядит это вот так:

Скриншот загружен в media
Скриншот загружен в media

Я считаю, что файлы редактора должны храниться в другой папке, например uploads и сохраняться по датам. Давайте настроим это:

Воспользуемся нашей папкой services, которую мы делали в этом уроке, и в файле utils.py мы добавим следующий код для кастомного расположения изображений:

services/utils.py
import os
from django.core.files.storage import FileSystemStorage
from backend import settings
from urllib.parse import urljoin
from datetime import datetime


class CkeditorCustomStorage(FileSystemStorage):
    """
    Кастомное расположение для медиа файлов редактора
    """
    def get_folder_name(self):
        return datetime.now().strftime('%Y/%m/%d')

    def get_valid_name(self, name):
        return name

    def _save(self, name, content):
        folder_name = self.get_folder_name()
        name = os.path.join(folder_name, self.get_valid_name(name))
        return super()._save(name, content)

    location = os.path.join(settings.MEDIA_ROOT, 'uploads/')
    base_url = urljoin(settings.MEDIA_URL, 'uploads/')

В примере выше метод get_folder_name возвращает имя папки в формате год/месяц/день. Метод get_valid_name позволяет модифицировать имя файла, если это необходимо. Метод _save определяет новый путь к файлу, который включает дату и вызывает оригинальный метод _save родительского класса.

Указываем в конфигурационном файле settings.py следующее свойство в самом конце:

backend/settings.py
CKEDITOR_5_FILE_STORAGE = 'modules.services.utils.CkeditorCustomStorage'

Попробуем добавить изображение в редакторе:

Добавляем скриншот в статью
Добавляем скриншот в статью
Теперь изображения загружаются в правильное расположение по дате
Теперь изображения загружаются в правильное расположение по дате
;