Как я могу отобразить неограниченное количество вложенных ответов (в стиле Reddit) в одном шаблоне Django?
Я создаю систему комментариев в стиле Reddit с помощью Django, и мне нужно, чтобы пользователи могли отвечать не только на публикацию, но и на любой комментарий неограниченной глубины.
Моя цель здесь в том, чтобы, когда пользователь отвечает на ответ, этот ответ сам по себе должен быть commentable, образуя настоящее дерево, а не просто два уровня. С приведенным ниже кодом: комментарии верхнего уровня отображаются нормально, прямые ответы на комментарии верхнего уровня отображаются, но ответы на эти ответы никогда не отображаются, даже если они правильно сохранены в базе данных.
Я бы хотел сохранить его в чистоте и избежать бесконечной вложенности {% для %}. Существует ли рекомендуемый шаблон шаблона Django (возможно, тег рекурсивного шаблона или пользовательский тег включения), который работает без нарушения CSRF для форм ответа?
Моя модель:
class Comment(models.Model):
post = models.ForeignKey(Post, on_delete=models.CASCADE, related_name="comments")
author = models.ForeignKey(
settings.AUTH_USER_MODEL,
on_delete=models.CASCADE,
related_name="comments"
)
up_vote = models.BigIntegerField(blank=True, null=True)
down_vote = models.BigIntegerField(blank=True, null=True)
parent = models.ForeignKey(
"self", null=True, blank=True, on_delete=models.CASCADE, related_name="replies"
)
body = models.TextField()
created_at = models.DateTimeField(default=timezone.now)
class Meta:
ordering = ["created_at"]
def __str__(self):
return f"Comment by {self.author} on {self.post}"
мои просмотры:
@login_required
def post_details(request, slug):
post = get_object_or_404(Post, slug=slug)
community = post.community
# Handle comment or reply POST
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
parent_id = request.POST.get("parent_id") # <-- may be blank
parent = Comment.objects.filter(id=parent_id).first() if parent_id else None
Comment.objects.create(
post=post,
author=request.user,
body=form.cleaned_data['body'],
parent=parent
)
return redirect('post_detail', slug=slug)
else:
form = CommentForm()
comments = (
Comment.objects
.filter(post=post, parent__isnull=True)
.select_related('author')
.prefetch_related('replies__author')
)
is_member = community.members.filter(id=request.user.id).exists()
return render(
request,
"post_detail.html",
{
"post": post,
"comments": comments,
"form": form,
"community": community,
"is_member": is_member,
"members_count": community.members.count(),
},
)
шаблон:
Лучший способ создать бесконечно глубокие вложенные комментарии (настоящую древовидную структуру) в шаблонах Django без бесконечной {% for %} вложенности и с сохранением CSRF для всех форм ответа - это использовать рекурсивный шаблон включения шаблона.
Моя текущая реализация выполняет итерации только на двух уровнях (ответы верхнего уровня и прямые ответы). Решение состоит в том, чтобы перенести логику отображения для одного комментария и ответов на него в отдельный шаблон, который вызывает сам себя.
Прежде всего, я создаю отдельный файл шаблона, например, comment_thread.html. Этот шаблон будет обрабатывать отображение отдельного элемента комментария, а затем рекурсивно включать себя для отображения любых ответов на этот элемент.
{% load static %}
<div class="d-flex mt-3 {% if is_reply %}ms-4{% endif %}">
<img src="{{ comment.author.profile.avatar.url }}" class="rounded-circle me-2"
style="width:{{ avatar_size }}px;height:{{ avatar_size }}px;object-fit:cover;">
<div>
<div class="fw-semibold text-light">{{ comment.author.username }}</div>
<small class="text-muted">{{ comment.created_at|timesince }} ago</small>
<p class="comment-body mt-1">{{ comment.body }}</p>
<a href="#" class="text-info text-decoration-none reply-toggle"
data-target="reply-form-{{ comment.id }}">Reply</a>
{% if user.is_authenticated %}
<form method="post" class="mt-2 d-none" id="reply-form-{{ comment.id }}">
{% csrf_token %}
{{ form.body }}
<input type="hidden" name="parent_id" value="{{ comment.id }}">
<button type="submit" class="btn btn-sm btn-secondary mt-1">Reply</button>
</form>
{% endif %}
{% for reply in comment.replies.all %}
{% include "comment_thread.html" with comment=reply is_reply=True form=form user=user avatar_size=32 %}
{% endfor %}
</div>
</div>
Во-вторых, я изменяю свой основной шаблон, чтобы выполнять итерацию только по комментариям верхнего уровня, а затем инициирую рекурсию, используя новый тег include.
<section id="comments" class="mt-5">
<h5 class="mb-4">{{ comments.count }} Comments</h5>
{% if user.is_authenticated %}
<form method="post" class="mb-4 d-flex gap-2">
{% csrf_token %}
<img src="{{ user.profile.avatar.url }}" class="rounded-circle"
style="width:40px;height:40px;object-fit:cover;">
<div class="flex-grow-1">
{{ form.body }}
<button type="submit" class="btn btn-sm btn-primary mt-2">Post Comment</button>
</div>
</form>
{% else %}
<p class="text-muted">Please <a href="{% url 'login' %}">login</a> to post a comment.</p>
{% endif %}
{% for comment in comments %}
{% include "comment_thread.html" with comment=comment is_reply=False form=form user=user avatar_size=40 %}
{% empty %}
<p class="text-muted">No comments yet. Be the first to comment!</p>
{% endfor %}
</section>
Эта строка {% include "comment_thread.html" with comment=reply is_reply=True form=form user=user avatar_size=32 %} является основной. Она передает объект reply обратно в тот же шаблон, повторяя процесс с потенциально бесконечной глубиной.
Хотя шаблон теперь обрабатывает бесконечную глубину, в моем текущем представлении предварительно выбирается только первый уровень ответов. Для более глубоких потоков это приведет к проблеме с запросом N+1, я думаю, это снизит производительность.
@login_required
def post_details(request, slug):
post = get_object_or_404(Post, slug=slug)
community = post.community
# Handle comment or reply POST
if request.method == "POST":
form = CommentForm(request.POST)
if form.is_valid():
parent_id = request.POST.get("parent_id") # <-- may be blank
parent = Comment.objects.filter(id=parent_id).first() if parent_id else None
Comment.objects.create(
post=post,
author=request.user,
body=form.cleaned_data['body'],
parent=parent
)
return redirect('post_detail', slug=slug)
else:
form = CommentForm()
comments = (
Comment.objects
.filter(post=post, parent__isnull=True)
.select_related('author')
# Use Prefetch to recursively fetch all replies
.prefetch_related(
'replies__author',
'replies__replies__author',
'replies__replies__replies__author',
'replies__replies__replies__replies__author',
)
)
is_member = community.members.filter(id=request.user.id).exists()
return render(
request,
"post_detail.html",
{
"post": post,
"comments": comments,
"form": form,
"community": community,
"is_member": is_member,
"members_count": community.members.count(),
},
)
Для по-настоящему надежного решения в PostgreSQL идеальным подходом является Recursive Common Table Expression (CTE) для извлечения всего дерева в одном запросе. Однако рекурсивный подход к шаблонам в сочетании с многоуровневой предварительной выборкой является самым простым и независимым от фреймворка способом решения моей непосредственной проблемы с шаблонами.