Создание шаблоны древовидных комментариев

Для начала я создам список комментариев к статье, для этого я в templates/modules/blog/ создам новую папку comments, следовательно в ней я создам comments-list.html

Более оптимизированный код на моём втором сайте

templates/modules/blog/comments/comments-list.html

{% load mptt_tags %}
<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.get_avatar }}" 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.created_at }}</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 %}

Пояснения: 

  • Самая простенькая реализация вывода комментариев к статье, без заморочек.
  • Добавил также форму.
  • Добавил везде необходимые id, data для JS.

Подключу файл выше к детальной статье:

templates/modules/blog/articles/article-detail.html

{% extends 'main.html' %}

{% block content %}
    <div class="card border-0 mb-3">
        <div class="card-body">
            <div class="row">
                <div class="col-md-4">
                    <figure>
                        <img src="{{ article.get_thumbnail }}" width="200" alt="{{ article.title }}">
                    </figure>
                </div>
                <div class="col-md-8">
                     <h5 class="card-title">
                        {{ article.title }}
                    </h5>
                    <small class="card-subtitle">
                        {{ article.created_at }} / {{ article.category }}
                    </small>
                    <div class="card-text">
                        {{ article.full_description }}
                    </div>
                     <hr/>
                      Добавил: <img src="{{ article.author.profile.get_avatar }}" class="rounded-circle" width="26" height="26"/> {{ article.author }}
                    <hr/>
                    {% if article.reason %}{{ article.reason }} / обновил: {{ article.updated_by }}{% endif %}
                </div>
            </div>
        </div>
    </div>
    <div class="card border-0">
        <div class="card-body">
            <h5 class="card-title">
                Комментарии
            </h5>
            {% include 'modules/blog/comments/comments-list.html' %}
        </div>
    </div>
{% endblock %}

Подправлю детальное представление статьи:

modules/blog/views/articles.py

 class ArticleDetailView(DetailView):
    model = Article
    template_name = 'modules/blog/articles/article-detail.html'
    context_object_name = 'article'
    queryset = Article.custom.detail()

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

Пояснения:

  • Я добавил context['form'] = CommentCreateForm для показа нашей формы в шаблоне.

Я добавлю пару комментариев к нашей тестовой статье в админке:

Как это выглядит под статьей:

Давайте сделаем возможность добавлять комментарий к статье, а также возможность отвечать на комментарий.

В 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 'plugins/bootstrap-5.2.0-dist/css/bootstrap.min.css' %}" type="text/css" rel="stylesheet">
</head>
<body>
<div class="container">
    {% include 'navbar.html' %}
    <div class="row">
        <div class="col-md-8">
            {% include 'includes/messages.html' %}
            {% block content %}

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

Пояснения:

  • custom/js/backend.js сейчас создадим.
  • block script необходим для подключения скриптов в необходимых нам шаблонах.

В папке src создаем папку custom, в ней папку js и два файла article-detail.js и backend.js

Далее подключим файл article-detail.js в article-detail.html

{% extends 'main.html' %}
{% load static %}

{% block content %}
    <div class="card border-0 mb-3">
        <div class="card-body">
            <div class="row">
                <div class="col-md-4">
                    <figure>
                        <img src="{{ article.get_thumbnail }}" width="200" alt="{{ article.title }}">
                    </figure>
                </div>
                <div class="col-md-8">
                     <h5 class="card-title">
                        {{ article.title }}
                    </h5>
                    <small class="card-subtitle">
                        {{ article.created_at }} / {{ article.category }}
                    </small>
                    <div class="card-text">
                        {{ article.full_description }}
                    </div>
                     <hr/>
                      Добавил: <img src="{{ article.author.profile.get_avatar }}" class="rounded-circle" width="26" height="26"/> {{ article.author }}
                    <hr/>
                    {% if article.reason %}{{ article.reason }} / обновил: {{ article.updated_by }}{% endif %}
                </div>
            </div>
        </div>
    </div>
    <div class="card border-0">
        <div class="card-body">
            <h5 class="card-title">
                Комментарии
            </h5>
            {% include 'modules/blog/comments/comments-list.html' %}
        </div>
    </div>
{% endblock %}
{% block script %}
<script src="{% static 'custom/js/article-detail.js' %}"></script>
{% endblock %}

Работаем с файлом article-detail.js

templates/src/custom/js/article-detail.js

const commentForm = document.forms.commentForm
const commentFormContent = commentForm.content
const commentFormParentInput = commentForm.parent
commentForm.addEventListener('submit', createComment)

replyUser()

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

function replyComment() {
    const commentMessage = this;
    const commentUsername = commentMessage.getAttribute('data-comment-username')
    const commentMessageId = commentMessage.getAttribute('data-comment-id');
    commentFormContent.value = `**${commentUsername}**, `
    commentFormParentInput.value = `${commentMessageId}`
}

function createComment(event) {
    event.preventDefault()
    const commentFormSubmit = commentForm.commentSubmit;
    const commentArticleId = commentForm.getAttribute('data-article-id');
    const commentNestedListBox = document.querySelector('.nested-comments');

    commentFormSubmit.disabled = true;
    commentFormSubmit.innerText = "Ожидаем ответа сервера";

    fetch(`/articles/${commentArticleId}/comments/create/`, {
        method: "POST",
        headers: {
            "X-CSRFToken": csrftoken,
            "X-Requested-With": "XMLHttpRequest",
        },
        body: new FormData(commentForm)
    }).then((response => response.json())).then((result) => {
        if (result['comment_is_child']) {
            const commentParentThreadList = document.querySelector(`#comment-thread-${result['comment_parent_id']}`);
            commentParentThreadList.innerHTML += `
               <ul id="comment-thread-${result['comment_id']}">
                    <li class="card border-0">
                        <div class="row">
                            <div class="col-md-2">
                                <img src="${result['comment_avatar']}" style="width: 120px;height: 120px;object-fit: cover;" alt="${result['comment_author']}"/>
                            </div>
                            <div class="col-md-10">
                                <div class="card-body">
                                    <h6 class="card-title">
                                        <a href="${result['comment_get_absolute_url']}">${result['comment_author']}</a>
                                    </h6>
                                    <p class="card-text">
                                        ${result['comment_content']}
                                    </p>
                                    <a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="${result['comment_id']}" data-comment-username="${result['comment_author']}">Ответить</a>
                                    <hr/>
                                    <time>${result['comment_created_at']}</time>
                                </div>
                            </div>
                        </div>
                    </li>
                </ul>
            `
        } else {
            commentNestedListBox.innerHTML += `
            <ul id="comment-thread-${result['comment_id']}">
                    <li class="card border-0">
                        <div class="row">
                            <div class="col-md-2">
                                <img src="${result['comment_avatar']}" style="width: 120px;height: 120px;object-fit: cover;" alt="${result['comment_author']}"/>
                            </div>
                            <div class="col-md-10">
                                <div class="card-body">
                                    <h6 class="card-title">
                                        <a href="${result['comment_get_absolute_url']}">${result['comment_author']}</a>
                                    </h6>
                                    <p class="card-text">
                                        ${result['comment_content']}
                                    </p>
                                    <a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="${result['comment_id']}" data-comment-username="${result['comment_author']}">Ответить</a>
                                    <hr/>
                                    <time>${result['comment_created_at']}</time>
                                </div>
                            </div>
                        </div>
                    </li>
                </ul>
            `
        }
        commentForm.reset()
        commentFormSubmit.disabled = false;
        commentFormSubmit.innerText = "Добавить комментарий";
        commentFormParentInput.value = null;
        replyUser()
    })
}

Пояснения:

  • commentForm - получаю форму с помощью document.forms по имени commentForm, которое мы задали нашей форме.
    • commentFormContent - наследуясь от найденной формы на странице получаю доступ к полю контента.
    • commentFormParentInput - наследуясь от найденной формы на странице получаю доступ к скрытому полю.
  • К commentForm добавляю событие submit и направляю на выполнение функции createComment
  • Инициализирую replyUser() функцию на странице для того, чтобы после отработки JS, ее можно было вновь использовать с добавленным комментарием через JS.
  • function replyUser() с помощью цикла forEach находит все объекты с классом "btn-reply", добавляю событие click и направляю на функцию replyComment()
    • Функция replyComment() использует this -  commentMessage.
    • Наследуясь от commentMessage мы получаем имя юзера на комментарий которого отвечаем из getAttribute('data-comment-username') и помещаем в переменную commentUsername
    • Наследуясь от commentMessage мы получаем id комментария на который отвечаем из getAttribute('data-comment-id') и помещаем в переменную commentMessageId
    • В контент формы (commentFormContent.value) вписываем имя, а в скрытое поле commentFormParentInput.value вписываем ID комментария, на который мы хотим ответить.
  • function createComment(event) при нажатии на кнопку добавить комментарий, происходит выполнение данной функции:
    • event.preventDefault() - прерываем стандартное поведение браузера (перезагрузку)
    • commentFormSubmit - получаем управление нашей кнопкой "Добавить комментарий"
    • commentArticleId - получаем из формы id статьи методом getAttribute('data-article-id');
    • commentNestedListBox - получаем со страницы блок с комментариями.
    • commentFormSubmit - при выполнении функции изменяем название кнопки, а также делаем ее выключенной.
    • Используем fetch запрос. fetch(`/articles/${commentArticleId}/comments/create/`) по нашему представлению, что мы добавили в прошлом уроке.
      • У нас это метод POST
      • В заголовки передаем "X-CSRFToken": csrftoken, "X-Requested-With": "XMLHttpRequest",
      • Передаем тело (body) нашу форму.
      • Обрабатываем ее в json формате, получаем результат и наконец выводим на страницу полученные данные:
    • Если result['comment_is_child'] = True, то мы добавляем комментарий к комментарию, на который мы отвечаем.
    • Если же родительского комментария нет, то мы добавляем комментарий в конец нашего блока комментариев.
    • После выполнения функции перезапускаем форму, изменяем кнопку и присваиваем пустое значение полю с родительским id. 
    • Снова инициализируем функцию replyUser() для вновь добавленного комментария на страницу.

Работаем с файлом backend.js

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");

Пояснения:

  • Получаем наш токен из Cookie.

Давайте посмотрим результат:

При нажатии на ответить:

Нажимаем добавить комментарий:

Отлично, мы смогли ответить. Давайте теперь сразу же добавим комментарий просто в статью:

Все отлично работает! Надеюсь у вас все получиться, каждый может написать JavaScript по-своему, добавляя туда множество других мелочей и возможностей. Это был самый базовый рабочий пример.

Ну и теперь нам нужно сделать последний шаг - оптимизировать.

Сейчас у нас 13 sql запросов и с каждым комментарием показатель будет расти.

Поэтому, мы идем в наш кастомный менеджер и добавляем наконец в метод def detail(self), который мы используем в ArticleDetailView() - prefetch_related()

modules/blog/managers/articles.py

from django.db import models


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

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

    def detail(self):
        """
        Детальная страница статьи с оптимизацией
        """
        return self.get_queryset().filter(is_published=True)\
            .select_related('category', 'updated_by', 'author', 'author__profile')\
            .prefetch_related('comments', 'comments__author', 'comments__author__profile')

Пояснения:

  • prefetch_related - это обратные связи. Комментарии ссылаются на статью. 
  • Значит, мы должны сделать join's комментариев, авторов комментариев и профилей авторов комментариев.

Таким образом, мы оптимизировали с 13 запросов до 7, которые не будут плодиться. Проблемы n + 1 больше не будет! 

Комментарии к статье 2
  • nikollia
    20 октября 2022 г. 0:09

    Отличный курс! Большое спасибо автору за подробное описание!

    • Razilator
      20 октября 2022 г. 6:56

      nikollia, благодарю. Рад, что Вам все нравится.

Форма добавления комментария (необходима регистрация)