Django База [2023]: Система древовидных комментариев -  добавление без перезагрузки страницы с помощью JavaScript (часть 2) 💬 #27
Django

Django База [2023]: Система древовидных комментариев - добавление без перезагрузки страницы с помощью JavaScript (часть 2) 💬 #27

Razilator

Эта статья посвящена созданию иерархических комментариев в Django 4.1 с помощью MPTT. Это вторая часть, которая содержит в себе настройку шаблонов, а также JavaScript код.

Предыдущая статья по реализации древовидных комментариев

Создание шаблона

Первое, что нам необходимо, это создать шаблон для списка комментариев к статье.

В каталоге templates/blog, создаем папку comments, где в свою очередь создаем файл comments_list.html.

Далее размечаем самую простую разметку на bootstrap в этом файле:

templates/blog/comments/comments_list.html
{% load mptt_tags static %}
<div class="nested-comments">
{% recursetree article.comments.all %}
<ul id="comment-thread-{{ node.pk }}">
    <li class="card border-0">
        <div class="row">
            <div class="col-md-2">
                <img src="{{ node.author.profile.avatar.url }}" style="width: 120px;height: 120px;object-fit: cover;" alt="{{ node.author }}"/>
            </div>
            <div class="col-md-10">
                <div class="card-body">
                    <h6 class="card-title">
                        <a href="{{ node.author.profile.get_absolute_url }}">{{ node.author }}</a>
                    </h6>
                    <p class="card-text">
                        {{ node.content }}
                    </p>
                    <a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="{{ node.pk }}" data-comment-username="{{ node.author }}">Ответить</a>
                    <hr/>
                    <time>{{ node.time_create }}</time>
                </div>
            </div>
        </div>
    </li>
     {% if not node.is_leaf_node %}
        {{ children }}
     {% endif %}
</ul>
{% endrecursetree %}
</div>

{% if request.user.is_authenticated %}
    <div class="card border-0">
       <div class="card-body">
          <h6 class="card-title">
             Форма добавления комментария
          </h6>
          <form method="post" action="{% url 'comment_create_view' article.pk %}" id="commentForm" name="commentForm" data-article-id="{{ article.pk }}">
             {% csrf_token %}
             {{ form }}
             <div class="d-grid gap-2 d-md-block mt-2">
                <button type="submit" class="btn btn-dark" id="commentSubmit">Добавить комментарий</button>
             </div>
          </form>
       </div>
    </div>
{% endif %}

{% block script %}
<script src="{% static 'custom/js/comments.js' %}"></script>
{% endblock %}

В примере выше я буду использовать форму комментариев только для авторизованных пользователей, также мы подключаем скрипт, который создадим позже.

Комментарии в шаблоне выводим с помощью дерева MPTT.

Переходим к детальной статье templates/blog/articles_detail.html, мы ее особо не редактировали, поэтому давайте изменим в ней всю разметку на следующую:

templates/blog/articles_detail.html
{% extends 'main.html' %}
{% load mptt_tags %}
{% block content %}
<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>{{ article.title }}</h5>
				<p class="card-text">{{ article.full_description }}</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>
</div>
<div class="card border-0">
	<div class="card-body">
		<h5 class="card-title">
			Комментарии
		</h5>
		{% include 'blog/comments/comments_list.html' %}
	</div>
</div>
{% endblock %}

В этом шаблоне мы подключаем наш список комментариев.

Далее нам нужно отредактировать main.html, где мы подключить скрипт для токенов, а также блок для скриптов.

templates/main.html
<!DOCTYPE html>
<html lang="ru">
<head>
    {% load static %}
    <meta charset="UTF-8">
    <title>{{ title }}</title>
    <meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
    <!-- INCLUDE CSS -->
    <link href="{% static 'bootstrap/css/bootstrap.min.css' %}" type="text/css" rel="stylesheet">
</head>
<body>
    <div class="container">
        {% include 'header.html' %}
        <div class="row">
            <div class="col-8">
            {% include 'includes/messages.html' %}
            {% block content %}

            {% endblock %}
            {% include 'pagination.html' %}
            </div>
            <div class="col-4">
                {% include 'sidebar.html' %}
            </div>
        </div>
    </div>
<script src="{% static 'bootstrap/js/bootstrap.bundle.min.js' %}"></script>
<script src="{% static 'custom/js/backend.js' %}"></script>
{% block script %}{% endblock %}
</body>
</html>

В этом файле мы подключаем токен, а также создаем блок для подключения скриптов в других файлах, например в comments_list.html.

Переходим к JavaScript коду.

Работа с JavaScript

В папке templates/src, которую мы создавали в уроке по подключению bootstrap, создаём папку custom, внутри нее папку js, в ней два файла: comments.js и backend.js.

Получится таким образом
Получится таким образом

В файл backend.js помещаем следующий JavaScript код:

templates/src/custom/js/backend.js
const getCookie = (name) => {
  let cookieValue = null;
  if (document.cookie && document.cookie !== "") {
    const cookies = document.cookie.split(";");
    for (let i = 0; i < cookies.length; i++) {
      const cookie = cookies[i].trim();
      // Does this cookie string begin with the name we want?
      if (cookie.substring(0, name.length + 1) === name + "=") {
        cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
        break;
      }
    }
  }
  return cookieValue;
};

const csrftoken = getCookie("csrftoken");

В примере выше: мы определяем функцию getCookie, которая принимает имя куки (cookie) и возвращает ее значение.

В этой функции проверяется, есть ли у нас сохраненные куки, и если да, то эти куки разделяются по ";" и перебираются в цикле.

Для каждого элемента, функция проверяет, начинается ли ее строковое представление с имени куки, которое мы ищем. Если да, то значение куки декодируется с помощью decodeURIComponent и сохраняется в переменной cookieValue, после чего цикл прерывается.

В конце функция возвращает значение cookieValue.

После этого, определяется переменная csrftoken, которая получает значение куки с именем csrftoken, вызвав функцию getCookie.

В файл comments.js мы добавляем следующий JavaScript код для добавления комментариев с помощью стандартного JavaScript с использованием fetch.

templates/src/custom/js/comments.js
const commentForm = document.forms.commentForm;
const commentFormContent = commentForm.content;
const commentFormParentInput = commentForm.parent;
const commentFormSubmit = commentForm.commentSubmit;
const commentArticleId = commentForm.getAttribute('data-article-id');

commentForm.addEventListener('submit', createComment);

replyUser()

function replyUser() {
  document.querySelectorAll('.btn-reply').forEach(e => {
    e.addEventListener('click', replyComment);
  });
}

function replyComment() {
  const commentUsername = this.getAttribute('data-comment-username');
  const commentMessageId = this.getAttribute('data-comment-id');
  commentFormContent.value = `${commentUsername}, `;
  commentFormParentInput.value = commentMessageId;
}
async function createComment(event) {
    event.preventDefault();
    commentFormSubmit.disabled = true;
    commentFormSubmit.innerText = "Ожидаем ответа сервера";
    try {
        const response = await fetch(`/articles/${commentArticleId}/comments/create/`, {
            method: 'POST',
            headers: {
                'X-CSRFToken': csrftoken,
                'X-Requested-With': 'XMLHttpRequest',
            },
            body: new FormData(commentForm),
        });
        const comment = await response.json();

        let commentTemplate = `<ul id="comment-thread-${comment.id}">
                                <li class="card border-0">
                                    <div class="row">
                                        <div class="col-md-2">
                                            <img src="${comment.avatar}" style="width: 120px;height: 120px;object-fit: cover;" alt="${comment.author}"/>
                                        </div>
                                        <div class="col-md-10">
                                            <div class="card-body">
                                                <h6 class="card-title">
                                                    <a href="${comment.get_absolute_url}">${comment.author}</a>
                                                </h6>
                                                <p class="card-text">
                                                    ${comment.content}
                                                </p>
                                                <a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="${comment.id}" data-comment-username="${comment.author}">Ответить</a>
                                                <hr/>
                                                <time>${comment.time_create}</time>
                                            </div>
                                        </div>
                                    </div>
                                </li>
                            </ul>`;
        if (comment.is_child) {
            document.querySelector(`#comment-thread-${comment.parent_id}`).insertAdjacentHTML("beforeend", commentTemplate);
        }
        else {
            document.querySelector('.nested-comments').insertAdjacentHTML("beforeend", commentTemplate)
        }
        commentForm.reset()
        commentFormSubmit.disabled = false;
        commentFormSubmit.innerText = "Добавить комментарий";
        commentFormParentInput.value = null;
        replyUser();
    }
    catch (error) {
        console.log(error)
    }
}

Этот код реализует функционал добавления комментариев к статье на сайте.

Он выполняет следующие функции:

Создает переменные для хранения формы комментария, содержания, родительского элемента комментария, кнопки отправки и ID статьи.

Назначает функцию createComment для события отправки формы комментария.

Функция replyUser добавляет обработчики события для кнопок "Ответить", которые позволяют пользователю ответить на другой комментарий.

Назначает функцию replyComment для кнопки "Ответить" каждого комментария, чтобы ответить на комментарий.

Определяет функцию createComment для отправки данных формы комментария на сервер. Она использует fetch API, чтобы отправить POST запрос на сервер для создания нового комментария.

Она также добавляет заголовки X-CSRFToken и X-Requested-With с текущим значением csrftoken и XMLHttpRequest, соответственно.

Далее мы получаем ответ от сервера в видео JSON, от созданного view: CommentCreateView, которое добавляем в переменную comment.

В переменной commentTemplate мы создаем шаблон, и добавляем полученные данные.

В функции также проверяется условие, является ли комментарий вложенным, или нет, и в зависимости от условия рендерится в определенном элементе.

Рассмотрим работу комментариев

Переходим в любую статью
Переходим в любую статью
Динамические отрабатываем JavaScript при добавлении комментария
Динамические отрабатываем JavaScript при добавлении комментария
Также работает и добавление ответа к комментарию, без перезагрузки
Также работает и добавление ответа к комментарию, без перезагрузки

Дополнительно, убираем N+1 проблему с комментариями

Так как всегда нужно решать все проблемы, мы должны убрать лишние SQL запросы, сейчас их столько:

Проблема N+1
Проблема N+1

С каждым комментарием это число будет увеличиваться. Поэтому переходим в менеджер статьи ArticleManager(), который мы создавали в этом уроке и добавляем новый метод - detail():

modules/blog/models.py
class Article(models.Model):
    """
    Модель постов для сайта
    """

    class ArticleManager(models.Manager):
        """
        Кастомный менеджер для модели статей
        """

        def all(self):
            """
            Список статей (SQL запрос с фильтрацией для страницы списка статей)
            """
            return self.get_queryset().select_related('author', 'category').filter(status='published')

        def detail(self):
            """
            Детальная статья (SQL запрос с фильтрацией для страницы со статьёй)
            """
            return self.get_queryset()\
                .select_related('author', 'category')\
                .prefetch_related('comments', 'comments__author', 'comments__author__profile')\
                .filter(status='published')

    STATUS_OPTIONS = (
        ('published', 'Опубликовано'),
        ('draft', 'Черновик')
    )

    title = models.CharField(verbose_name='Заголовок', max_length=255)
    slug = models.SlugField(verbose_name='Альт.название', max_length=255, blank=True, unique=True)
    short_description = models.TextField(verbose_name='Краткое описание', max_length=500)
    full_description = models.TextField(verbose_name='Полное описание')
    thumbnail = models.ImageField(
        verbose_name='Превью поста',
        blank=True,
        upload_to='images/thumbnails/%Y/%m/%d/',
        validators=[FileExtensionValidator(allowed_extensions=('png', 'jpg', 'webp', 'jpeg', 'gif'))]
    )
    status = models.CharField(choices=STATUS_OPTIONS, default='published', verbose_name='Статус поста', max_length=10)
    time_create = models.DateTimeField(auto_now_add=True, verbose_name='Время добавления')
    time_update = models.DateTimeField(auto_now=True, verbose_name='Время обновления')
    author = models.ForeignKey(to=User, verbose_name='Автор', on_delete=models.SET_DEFAULT, related_name='author_posts',
                               default=1)
    updater = models.ForeignKey(to=User, verbose_name='Обновил', on_delete=models.SET_NULL, null=True,
                                related_name='updater_posts', blank=True)
    fixed = models.BooleanField(verbose_name='Зафиксировано', default=False)
    category = TreeForeignKey('Category', on_delete=models.PROTECT, related_name='articles', verbose_name='Категория')

    objects = ArticleManager()

    class Meta:
        db_table = 'app_articles'
        ordering = ['-time_create', '-fixed']
        indexes = [models.Index(fields=['-time_create', '-fixed'])]
        verbose_name = 'Статья'
        verbose_name_plural = 'Статьи'

    def __str__(self):
        return self.title

    def get_absolute_url(self):
        return reverse('articles_detail', kwargs={'slug': self.slug})

    def save(self, *args, **kwargs):
        """
        Сохранение полей модели при их отсутствии заполнения
        """
        if not self.slug:
            self.slug = unique_slugify(self, self.title)
        super().save(*args, **kwargs)

В этом менеджере мы добавляем метод detail(), который можем использовать в представлениях, например в DetailView, который оптимизирует SQL запросы.

Отредактируем код представлений ArticleDetailView в файле views.py нашего приложения blog.

blog/views.py
class ArticleDetailView(DetailView):
    model = Article
    template_name = 'blog/articles_detail.html'
    context_object_name = 'article'
    queryset = model.objects.detail()

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        context['title'] = self.object.title
        context['form'] = CommentCreateForm
        return context

В этом коде мы добавили параметр queryset, где указали от нашей модели Article метод менеджера detail(), содержащий в себе оптимизацию SQL.

Посмотрим результат:

Вместо 10 SQL мы видим 8 SQL, и это значение больше не вырастет
Вместо 10 SQL мы видим 8 SQL, и это значение больше не вырастет

Отлично, все работает. Надеюсь и у вас все получилось!

;