Система вложенных комментариев Django 4.1 с помощью MPTT и JS 🌳
В этой статье мы разберем создание системы вложенных (древовидных) комментариев для Django, используя пакет MPTT и JavaScript. Мы создадим модель комментариев с возможностью комментирования как пользователям сайта, так и гостям сайта. И все это будет работать без перезагрузки страницы.
Если вы хотите выразить благодарность автору сайта, статей и курса по Django, вы можете сделать это по ссылке ниже:
MPTT (Modified Preorder Tree Traversal) - это библиотека для работы с древовидными структурами данных в Django. Она позволяет работать с моделями Django, которые могут содержать древовидные структуры данных, такие как категории, теги, комментарии и т.д. Библиотека MPTT использует алгоритм обхода дерева с модификацией предыдущего порядка (MPTT) для быстрого и эффективного доступа к дочерним элементам.
Установка Django MPTT
Первым шагом является установка MPTT с помощью pip, в терминале с помощью следующей команды устанавливаем MPTT: pip install django-mptt
Далее, необходимо добавить mptt в INSTALLED_APPS в settings.py:
INSTALLED_APPS = [
...
'mptt',
...
]
Модель комментариев MPTT для авторизованных и неавторизованных пользователей
Мы создадим систему комментариев, в которой смогут комментировать материалы как гости сайта, так и пользователи.
Предположим, что модель статей у вас уже создана. Или можете ее создать из этого урока.
from django.db import models
from django.contrib.auth import get_user_model
from mptt.models import MPTTModel, TreeForeignKey
User = get_user_model()
class Comment(MPTTModel):
"""
Модель древовидных комментариев
"""
STATUS_OPTIONS = (
('published', 'Опубликовано'),
('draft', 'Черновик')
)
article = models.ForeignKey(Article, on_delete=models.CASCADE, verbose_name='Статья', related_name='comments')
# Автор комментария (пользователь) если авторизован
author = models.ForeignKey(User, verbose_name='Автор комментария', on_delete=models.CASCADE, related_name='comments_author', null=True, blank=True)
# Гости сайта, если неавторизованные
name = models.CharField(max_length=255, verbose_name='Имя посетителя', blank=True)
email = models.EmailField(max_length=255, verbose_name='Email посетителя', blank=True)
# Прочие поля
content = models.TextField(verbose_name='Текст комментария', max_length=3000)
time_create = models.DateTimeField(verbose_name='Время добавления', auto_now_add=True)
time_update = models.DateTimeField(verbose_name='Время обновления', auto_now=True)
status = models.CharField(choices=STATUS_OPTIONS, default='published', verbose_name='Статус поста', max_length=10)
parent = TreeForeignKey('self', verbose_name='Родительский комментарий', null=True, blank=True,
related_name='children', on_delete=models.CASCADE)
class MTTMeta:
order_insertion_by = ('-time_create',)
class Meta:
db_table = 'app_comments'
indexes = [models.Index(fields=['-time_create', 'time_update', 'status', 'parent'])]
ordering = ['-time_create']
verbose_name = 'Комментарий'
verbose_name_plural = 'Комментарии'
def __str__(self):
if self.author:
return f'{self.author}:{self.content}'
else:
return f'{self.name} ({self.email}):{self.content}'
@property
def get_avatar(self):
if self.author:
return self.author.profile.get_avatar
return f'https://ui-avatars.com/api/?size=190&background=random&name={self.name}'
Эта модель представляет собой класс Comment, который является наследником класса MPTTModel.
Описание полей класса Comment:
article
- внешний ключ на модель Article, которая представляет статью, к которой относится данный комментарийauthor
- внешний ключ на модель User, который относится к автору комментария, если он авторизован, или None, если комментарий был оставлен гостемname
- имя посетителя, если он гостьemail
- email посетителя, если он гостьcontent
- текст комментарияtime_create
- время создания комментарияtime_update
- время последнего обновления комментарияstatus
- статус комментария, который может быть опубликован или черновикparent
- ссылка на родительский комментарий (для вложенных комментариев)
Для того, чтобы отображать имя пользователя для авторизованных пользователей и имя гостя для неавторизованных пользователей в методе str()
было добавлено условие. Если поле author установлено, то метод вернет "имя пользователя: текст комментария", в противном случае вернется "имя гостя (email): текст комментария".
Также мы добавим метод get_avatar()
, который будет выводить аватарку автора комментария (если она есть в профиле и это пользователь оставил комментарий) или выводить сгенерированную картинку исходя из имени гостя, который оставил комментарий. Этот метод мы создавали в статье по созданию профилей пользователя.
Не забываем про миграции: py manage.py makemigrations
и py manage.py migrate
Создание формы для MPTT комментариев
В приложении, где мы создали модель комментариев, создадим файл forms.py, куда поместим следующий код:
class CommentCreateForm(forms.ModelForm):
"""
Форма добавления комментариев к статьям
"""
parent = forms.IntegerField(widget=forms.HiddenInput, required=False)
content = forms.CharField(label='', widget=forms.Textarea(attrs={'cols': 30, 'rows': 5, 'placeholder': 'Комментарий', 'class': 'form-control'}))
name = forms.CharField(label='Имя', max_length=255, widget=forms.TextInput(attrs={'placeholder': 'Введите ваше имя', 'class': 'form-control'}), required=False)
email = forms.EmailField(label='Email', max_length=255, widget=forms.TextInput(attrs={'placeholder': 'Введите ваш email', 'class': 'form-control'}), required=False)
class Meta:
model = Comment
fields = ('content', 'name', 'email')
Создаем представление вложенных комментариев для работы с JS (Ajax/Fetch)
from django.views.generic import CreateView
from django.http import JsonResponse
from django.shortcuts import redirect
from .models import Comment
from .forms import CommentCreateForm
class CommentCreateView(CreateView):
model = Comment
form_class = CommentCreateForm
def is_ajax(self):
return self.request.headers.get('X-Requested-With') == 'XMLHttpRequest'
def form_valid(self, form):
comment = form.save(commit=False)
comment.article_id = self.kwargs.get('pk')
if self.request.user.is_authenticated:
comment.author = self.request.user
comment.name = self.request.user.username
comment.email = self.request.user.email
else:
comment.name = form.cleaned_data.get('name')
comment.email = form.cleaned_data.get('email')
comment.parent_id = form.cleaned_data.get('parent')
comment.save()
if self.is_ajax():
if self.request.user.is_authenticated:
data = {
'is_child': comment.is_child_node(),
'id': comment.id,
'author': comment.author.username,
'parent_id': comment.parent_id,
'time_create': comment.time_create.strftime('%Y-%b-%d %H:%M:%S'),
'avatar': comment.get_avatar,
'content': comment.content,
'get_absolute_url': comment.author.profile.get_absolute_url()
}
else:
data = {
'is_child': comment.is_child_node(),
'id': comment.id,
'author': comment.name,
'parent_id': comment.parent_id,
'time_create': comment.time_create.strftime('%Y-%b-%d %H:%M:%S'),
'avatar': comment.get_avatar,
'content': comment.content,
'get_absolute_url': f'mailto:{comment.email}'
}
return JsonResponse(data, status=200)
return redirect(comment.article.get_absolute_url())
Данный код представляет собой класс CommentCreateView, который наследуется от CreateView - представления для создания объектов в базе данных.
В атрибутах класса определены следующие параметры:
model
= Comment - указывает на модель Comment, которую мы будем использовать для создания объекта.form_class
= CommentCreateForm - указывает на форму CommentCreateForm, которую мы будем использовать для создания объекта.template_name
= 'blog/comment_create.html' - указывает на шаблон, который мы будем использовать для отображения формы создания комментария.
Далее, в классе CommentCreateView определены два метода:
Метод is_ajax()
определяет, был ли отправлен запрос с помощью AJAX. Если да, то метод возвращает True, иначе - False.
Метод form_valid()
вызывается при отправке формы и сохраняет данные в базу данных. Если был отправлен AJAX-запрос, то метод возвращает JsonResponse с данными объекта comment в формате JSON. Если запрос не был отправлен через AJAX, то метод перенаправляет пользователя на страницу, на которой был создан комментарий.
Также в методе form_valid()
определяется автор комментария: если пользователь авторизован, то автором комментария будет он и его name и email, иначе - комментарий будет создан гостем, и соответствующие данные (имя и email) будут записаны в комментарий из формы.
В зависимости от того, авторизован ли пользователь, метод form_valid()
возвращает разные значения JsonResponse. Если пользователь авторизован, возвращается JSON-объект, содержащий информацию об авторе комментария и ссылку на его аватар. Если пользователь не авторизован, возвращается JSON-объект, содержащий информацию об имени и email гостя, создавшего комментарий
Далее обавим обработку нашей формы в детальную статью, чтоб мы могли вывести форму в шаблоне статьи:
from django.views.generic import DetailView
from .forms import CommentCreateForm
from .models import Article
class ArticleDetailView(DetailView):
model = Article
template_name = 'blog/articles_detail.html'
context_object_name = 'article'
def get_context_data(self, **kwargs):
context = super().get_context_data(**kwargs)
context['title'] = self.object.title
context['form'] = CommentCreateForm
return context
Обработка представления для MPTT комментариев
Нам необходимо обрабатывать представление, поэтому мы добавим следующую строку в urls.py нашего приложения, где передадим id статьи, который получим впоследствии из JavaScript:
from django.urls import path
from .views import ArticleListView, ArticleDetailView, ArticleCreateView, \
ArticleUpdateView, ArticleDeleteView, CommentCreateView
urlpatterns = [
path('', ArticleListView.as_view(), name='home'),
path('articles/create/', ArticleCreateView.as_view(), name='articles_create'),
path('articles/<str:slug>/update/', ArticleUpdateView.as_view(), name='articles_update'),
path('articles/<str:slug>/delete/', ArticleDeleteView.as_view(), name='articles_delete'),
path('articles/<str:slug>/', ArticleDetailView.as_view(), name='articles_detail'),
path('articles/<int:pk>/comments/create/', CommentCreateView.as_view(), name='comment_create_view'),
]
Зарегистрируем модель MPTT комментариев с вложенностями в админ-панеле
Для этого в файле admin.py нашего приложения добавляем следующий код:
from django.contrib import admin
from mptt.admin import DraggableMPTTAdmin
from .models import Comment
@admin.register(Comment)
class CommentAdminPage(DraggableMPTTAdmin):
"""
Админ-панель модели комментариев
"""
list_display = ('tree_actions', 'indented_title', 'article', 'email', 'name', 'author', 'time_create', 'status')
mptt_level_indent = 2
list_display_links = ('article',)
list_filter = ('time_create', 'time_update')
list_editable = ('status',)
Выглядит это следующим образом:
Добавление формы и комментариев в шаблон статьи
Предположим, что у нас есть шаблон статьи, за основу будет взят шаблон из моего курса по Django:
{% extends 'main.html' %}
{% load static %}
{% 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 class="rating-buttons">
<button class="btn btn-sm btn-primary" data-article="{{ article.id }}" data-value="1">Лайк</button>
<button class="btn btn-sm btn-secondary" data-article="{{ article.id }}" data-value="-1">Дизлайк</button>
<button class="btn btn-sm btn-secondary rating-sum">{{ article.get_sum_rating }}</button>
</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 %}
Шаблон комментариев 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.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">
{% if node.author %}
<a href="{{ node.author.profile.get_absolute_url }}">
{{ node.author }}
</a>
{% else %}
<a href="mailto:{{ node.email}}">
{{ node.name }}
</a>
{% endif %}
<p class="card-text">
{{ node.content }}
</p>
{% if node.author %}
<a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="{{ node.pk }}" data-comment-username="{{ node.author }}">Ответить</a>
{% else %}
<a class="btn btn-sm btn-dark btn-reply" href="#commentForm" data-comment-id="{{ node.pk }}" data-comment-username="{{ node.name }}">Ответить</a>
{% endif %}
<hr />
<time>{{ node.time_create }}</time>
</div>
</div>
</div>
</li>
{% if not node.is_leaf_node %} {{ children }} {% endif %}
</ul>
{% endrecursetree %}
</div>
<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.content }}
{{ form.parent }}
{% if not request.user.is_authenticated %}
<div class="mb-3">
<label for="id_name">{{ form.name.label }}:</label>
<input type="text" name="name" placeholder="Введите ваше имя" required="true" class="form-control" maxlength="255" id="id_name">
</div>
<div class="mb-3">
<label for="id_email">{{ form.email.label }}:</label>
<input type="text" name="email" placeholder="Введите ваш email" required="true" class="form-control" maxlength="255" id="id_email">
</div>
{% endif %}
<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>
{% block script %}
<script src="{% static 'custom/js/comments.js' %}"></script>
{% endblock %}
Как выглядит это в шаблоне:
Подключение JavaScript для MPTT комментариев
Теперь нам необходимо добавить соответствующие js файлы, один для получения csrftoken токена, создадим такой файл в папке src/custom/js/backend.js:
В этом уроке есть знания по этому коду, а также по обработке папки src.
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");
Подключим данный файл где-нибудь в 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">
{% block sidebar %}{% endblock %}
{% 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>
Тут же мы и создали блок для js скриптов {% block script %}{% endblock %}
, который используем в списке комментариев с формой.
Настало время для создания js файла для самих комментариев, создаем файл comments.js в папке src/custom/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)
}
}
Ниже приведено объяснение каждой строки кода:
const commentForm = document.forms.commentForm;
- получаем элемент формы по имени commentForm.const commentFormContent = commentForm.content;
- получаем поле ввода комментария из формы.const commentFormParentInput = commentForm.parent;
- получаем скрытое поле, которое хранит ID комментария, к которому будет ответ.const commentFormSubmit = commentForm.commentSubmit;
- получаем кнопку отправки комментария.const commentArticleId = commentForm.getAttribute('data-article-id');
- получаем ID статьи, к которой будет добавлен комментарий.commentForm.addEventListener('submit', createComment);
- добавляем обработчик события submit на форму.replyUser()
- вызываем функцию replyUser, которая добавляет обработчик события click на все кнопки с классомbtn-reply
.function replyUser() { ... }
- определяем функцию replyUser, которая добавляет обработчик события click на все кнопки с классомbtn-reply
и вызывает функциюreplyComment
.function replyComment() { ... }
- определяем функцию replyComment, которая устанавливает значение поляcontent
формы для ответа на комментарий, а также устанавливает значение поляparent
для идентификатора комментария, на который мы отвечаем.async function createComment(event) { ... }
- определяем асинхронную функцию createComment, которая отправляет данные формы на сервер, получает ответ в формате JSON и добавляет новый комментарий на страницу. Она также сбрасывает значения полей формы и активирует кнопку отправки после добавления комментария.