Создание древовидных комментариев
Комментарии с ответами
В блоге из модуля 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
Объявите модель 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".
from mptt.models import MPTTModel, TreeForeignKey
class Comment(MPTTModel):
article = models.ForeignKey(Article, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(User, on_delete=models.CASCADE, related_name="comments_author")
content = models.TextField(max_length=3000)
parent = TreeForeignKey("self", on_delete=models.CASCADE, null=True, blank=True, related_name="children")
В Comment добавьте class MPTTMeta с сортировкой узлов одного уровня по дате:
order_insertion_by = ("-time_create",).
class Comment(MPTTModel):
time_create = models.DateTimeField(auto_now_add=True)
class MPTTMeta:
order_insertion_by = ("-time_create",)
В CommentCreateView.form_valid заполните недостающие поля комментария: article_id — из
self.kwargs.get("pk"), author — текущий пользователь, parent_id — из form.cleaned_data.get("parent").
Сохраните комментарий и сделайте редирект на comment.article.get_absolute_url().
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())
Зачем в форме комментария скрытое поле parent?
Выведите дерево комментариев статьи: {% recursetree article.comments.all %}, внутри для узла —
{{ node.content }}, а если у узла есть дети ({% if not node.is_leaf_node %}) — {{ children }}.
{% load mptt_tags %}
{% recursetree article.comments.all %}
<div class="comment">
{{ node.content }}
{% if not node.is_leaf_node %}
<div class="ms-4">{{ children }}</div>
{% endif %}
</div>
{% endrecursetree %}
Войдите, чтобы отмечать пройденные уроки.