Создание шаблоны древовидных комментариев
Для начала я создам список комментариев к статье, для этого я в 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 больше не будет!

nikollia
Отличный курс! Большое спасибо автору за подробное описание!
Razilator
nikollia, благодарю. Рад, что Вам все нравится.