Как я могу отобразить неограниченное количество вложенных ответов (в стиле 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) для извлечения всего дерева в одном запросе. Однако рекурсивный подход к шаблонам в сочетании с многоуровневой предварительной выборкой является самым простым и независимым от фреймворка способом решения моей непосредственной проблемы с шаблонами.

Вернуться на верх