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

⏱ 30 мин · 🧩 заданий: 5

Комментарии с ответами

В блоге из модуля 6 комментарии шли плоским списком. На сайте статей сделаем дерево: на любой комментарий можно ответить, ответы показываются с отступом. Структуру храним так же, как категории, — на django-mptt.

Модель

modules/blog/models.py:

from mptt.models import MPTTModel, TreeForeignKey


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")
    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 MPTTMeta:
        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):
        return f"{self.author}: {self.content[:30]}"
  • parent = TreeForeignKey("self", ...) — ссылка на комментарий, на который отвечают; у корневых NULL;
  • MPTTMeta.order_insertion_by — порядок узлов одного уровня.
python manage.py makemigrations
python manage.py migrate

В админке — DraggableMPTTAdmin, как для категорий:

from mptt.admin import DraggableMPTTAdmin


@admin.register(Comment)
class CommentAdminPage(DraggableMPTTAdmin):
    list_display = ("tree_actions", "indented_title", "article", "author", "time_create", "status")
    list_display_links = ("article",)
    list_filter = ("time_create", "time_update", "author")
    list_editable = ("status",)

Форма

modules/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"}),
    )

    class Meta:
        model = Comment
        fields = ("content",)

Скрытое поле parent — id комментария, на который отвечают. Пустое значение — комментарий верхнего уровня.

Представление

class CommentCreateView(LoginRequiredMixin, CreateView):
    form_class = CommentCreateForm

    def form_valid(self, form):
        comment = form.save(commit=False)
        comment.article_id = self.kwargs.get("pk")
        comment.author = self.request.user
        comment.parent_id = form.cleaned_data.get("parent")
        form.save()
        return redirect(comment.article.get_absolute_url())

GET-шаблон этому представлению не нужен: форма живёт на странице статьи.

path("articles/<int:pk>/comments/create/", CommentCreateView.as_view(), name="comment_create_view"),

Форму передаём в контекст детальной страницы:

class ArticleDetailView(DetailView):
    ...

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

Шаблон

templates/blog/comments/comments_list.html, подключается на детальной странице:

{% load mptt_tags static %}
<div class="card border-0">
  <div class="card-body">
    <h5 class="card-title">Комментарии</h5>
    {% include "blog/comments/comments_list_item.html" %}
  </div>
</div>

{% if request.user.is_authenticated %}
  <form method="post" action="{% url 'comment_create_view' article.pk %}" id="commentForm" name="commentForm">
    {% csrf_token %}
    {{ form }}
    <button type="submit" class="btn btn-primary mt-2" id="commentSubmit">Добавить комментарий</button>
  </form>
{% else %}
  <p><a href="{% url 'login' %}">Войдите</a>, чтобы оставить комментарий.</p>
{% endif %}

templates/blog/comments/comments_list_item.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.avatar.url }}" style="width: 80px; height: 80px; 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 btnReply" 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>

Каждый <ul> вложен в родительский, поэтому ответы сдвигаются вправо. Для глубоких веток ограничьте отступ в CSS:

.nested-comments ul { padding-left: 1.5rem; }
.nested-comments ul ul ul ul { padding-left: 0; }

Кнопка «Ответить» пока ничего не делает — в следующем уроке подставим parent и будем отправлять форму без перезагрузки страницы.

Не забудьте оптимизацию: в ArticleDetailView для комментариев с авторами и профилями добавьте prefetch_related с Prefetch("comments", queryset=Comment.objects.select_related("author__profile")).

Итог

  • Comment(MPTTModel) с parent = TreeForeignKey("self") — комментарии с ответами любой глубины.
  • Форма со скрытым полем parent, CreateView подставляет статью, автора и родителя в form_valid().
  • Вывод — recursetree по article.comments.all, вложенные <ul>.

Практика решено 0 из 5

Вы не вошли — ответы проверяются, но прогресс не сохранится. Войти или зарегистрироваться.
Задание 1 Написать код

Объявите модель Comment(MPTTModel) с полями: article — ForeignKey на Article (CASCADE, related_name="comments"), author — ForeignKey на User (CASCADE, related_name="comments_author"), content — TextField, parent — TreeForeignKey("self", ...) с null=True, blank=True, on_delete=models.CASCADE, related_name="children".

Задание 2 Написать код

В Comment добавьте class MPTTMeta с сортировкой узлов одного уровня по дате: order_insertion_by = ("-time_create",).

Задание 3 Написать код

В CommentCreateView.form_valid заполните недостающие поля комментария: article_id — из self.kwargs.get("pk"), author — текущий пользователь, parent_id — из form.cleaned_data.get("parent"). Сохраните комментарий и сделайте редирект на comment.article.get_absolute_url().

Задание 4 Один вариант ответа

Зачем в форме комментария скрытое поле parent?

Задание 5 Написать код

Выведите дерево комментариев статьи: {% recursetree article.comments.all %}, внутри для узла — {{ node.content }}, а если у узла есть дети ({% if not node.is_leaf_node %}) — {{ children }}.

Войдите, чтобы отмечать пройденные уроки.