Система вложенных комментариев Django 4.1 с помощью MPTT и JS 🌳
Django

Система вложенных комментариев Django 4.1 с помощью MPTT и JS 🌳

Razilator

В этой статье мы разберем создание системы вложенных (древовидных) комментариев для Django, используя пакет MPTT и JavaScript. Мы создадим модель комментариев с возможностью комментирования как пользователям сайта, так и гостям сайта. И все это будет работать без перезагрузки страницы.

MPTT (Modified Preorder Tree Traversal) - это библиотека для работы с древовидными структурами данных в Django. Она позволяет работать с моделями Django, которые могут содержать древовидные структуры данных, такие как категории, теги, комментарии и т.д. Библиотека MPTT использует алгоритм обхода дерева с модификацией предыдущего порядка (MPTT) для быстрого и эффективного доступа к дочерним элементам.

Установка Django MPTT

Первым шагом является установка MPTT с помощью pip, в терминале с помощью следующей команды устанавливаем MPTT: pip install django-mptt

Далее, необходимо добавить mptt в INSTALLED_APPS в settings.py:

settings.py
INSTALLED_APPS = [
    ...
    'mptt',
    ...
]

Модель комментариев MPTT для авторизованных и неавторизованных пользователей

Мы создадим систему комментариев, в которой смогут комментировать материалы как гости сайта, так и пользователи.

Предположим, что модель статей у вас уже создана. Или можете ее создать из этого урока.

blog/models.py
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, куда поместим следующий код:

blog/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)

blog/views.py
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 гостя, создавшего комментарий

Далее обавим обработку нашей формы в детальную статью, чтоб мы могли вывести форму в шаблоне статьи:

blog/views.py
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:

blog/urls.py
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 нашего приложения добавляем следующий код:

blog/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:

templates/blog/articles_detail.html
{% 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:

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 %}

Как выглядит это в шаблоне:

Когда пользователь авторизован на сайте
Когда пользователь авторизован на сайте
Когда пользователь не авторизован, ему необходимо ввести имя и email
Когда пользователь не авторизован, ему необходимо ввести имя и email

Подключение JavaScript для MPTT комментариев

Теперь нам необходимо добавить соответствующие js файлы, один для получения csrftoken токена, создадим такой файл в папке src/custom/js/backend.js:

В этом уроке есть знания по этому коду, а также по обработке папки src.

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

Подключим данный файл где-нибудь в 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">
                {% 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 со следующим кодом:

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)
    }
}

Ниже приведено объяснение каждой строки кода:

  • 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 и добавляет новый комментарий на страницу. Она также сбрасывает значения полей формы и активирует кнопку отправки после добавления комментария.

Тестируем MPTT комментарии на сайте

Добавляем комментарий с авторизованного пользователя
Добавляем комментарий с авторизованного пользователя
Добавляем гостевой вложенный комментарий
Добавляем гостевой вложенный комментарий
Ссылка у гостевого акканта будет в виде отправки письма на почту гостя
Ссылка у гостевого акканта будет в виде отправки письма на почту гостя
;