PythonDjango

Djangoで、コメントへの返信を再帰的に取り出す

概要

このブログでは記事にコメントができ、そのコメントに対して返信ができます。

しかし場合によっては、返信に対しての返信、それに対しての返信...のように、無限に行いたい場合もあるかもしれません。

今回はブログのコメントを例にしますが、再帰的なモデルの構造を作りたい場合は同様に実装できると思います。

デモ

次のような感じで、記事にコメントができ、それに対して返信、またそれに対して...といったことが行えます。
無限にコメント返信ができる

ソースコードのダウンロード

Githubに置いています。

models.py

次のようなモデルを作成しておきます。

from django.db import models


class Post(models.Model):
    title = models.CharField('記事タイトル', max_length=255)
    text = models.TextField('記事本文')

    def __str__(self):
        return self.title


class Comment(models.Model):
    text = models.TextField('コメント内容')
    post = models.ForeignKey(Post, verbose_name='対象記事', on_delete=models.CASCADE)
    parent = models.ForeignKey('self', verbose_name='親コメント', null=True, blank=True, on_delete=models.CASCADE)

    def __str__(self):
        return self.text[:10]

Postモデルは特に説明することはありません。

Commentモデルのpostは、どの記事に紐づくかを表すフィールドです。これも一般的に見かけますね。

parentフィールドが少し特殊で、そのコメントが他コメントへの返信だった場合は、返信対象となるコメントが紐づきます。ForeignKey('self'というのは、ForeignKey(Comment)と書きたいけど諸事情でそう書けないので、selfとする必要があるためです。親コメントがない場合...他コメントへの返信ではなく記事へのコメントの場合はparentを空欄にしたいので、null=True, blank=Trueを忘れないようにしましょう。

このselfは案外便利で、ブログならば記事の関連記事なんかも同様に表現できます。

urls,py

記事の一覧、記事の詳細、コメントの作成、返信の作成の4つを定義しておきます。

from django.urls import path
from . import views

app_name = 'blog'

urlpatterns = [
    path('', views.PostList.as_view(), name='post_list'),
    path('detail/<int:pk>/', views.PostDetail.as_view(), name='post_detail'),
    path('comment/<int:post_pk>/', views.comment_create, name='comment_create'),
    path('reply/<int:comment_pk>/', views.reply_create, name='reply_create'),
]

views.py

処理をわかりやすくするため、コメントとリプライの作成は関数ビューにしています。

from django import forms
from django.shortcuts import get_object_or_404, redirect, render
from django.views import generic
from .models import Post, Comment

# コメント、返信フォーム
CommentForm = forms.modelform_factory(Comment, fields=('text', ))


class PostList(generic.ListView):
    """記事一覧"""
    model = Post


class PostDetail(generic.DetailView):
    """記事詳細"""
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # どのコメントにも紐づかないコメント=記事自体へのコメント を取得する
        context['comment_list'] = self.object.comment_set.filter(parent__isnull=True)
        return context


def comment_create(request, post_pk):
    """記事へのコメント作成"""
    post = get_object_or_404(Post, pk=post_pk)
    form = CommentForm(request.POST or None)

    if request.method == 'POST':
        comment = form.save(commit=False)
        comment.post = post
        comment.save()
        return redirect('blog:post_detail', pk=post.pk)

    context = {
        'form': form,
        'post': post
    }
    return render(request, 'blog/comment_form.html', context)


def reply_create(request, comment_pk):
    """コメントへの返信"""
    comment = get_object_or_404(Comment, pk=comment_pk)
    post = comment.post
    form = CommentForm(request.POST or None)

    if request.method == 'POST':
        reply = form.save(commit=False)
        reply.parent = comment
        reply.post = post
        reply.save()
        return redirect('blog:post_detail', pk=post.pk)

    context = {
        'form': form,
        'post': post,
        'comment': comment,
    }
    return render(request, 'blog/comment_form.html', context)

次の部分は、モデルフォームクラスの作成処理です。シンプルなモデルフォームならば、このように作ることもできます。CreateView等で自動的にモデルフォームを作ってくれますが、それと同じです。

# コメント、返信フォーム
CommentForm = forms.modelform_factory(Comment, fields=('text', ))
記事の一覧は、特に説明不要だと思います。

class PostList(generic.ListView):
    """記事一覧"""
    model = Post
記事詳細は少し追加の処理があります。

class PostDetail(generic.DetailView):
    """記事詳細"""
    model = Post

    def get_context_data(self, **kwargs):
        context = super().get_context_data(**kwargs)
        # どのコメントにも紐づかないコメント=記事自体へのコメント を取得する
        context['comment_list'] = self.object.comment_set.filter(parent__isnull=True)
        return context

テンプレートで{% for comment in post.comment_set.all %}のようにしてしまうと、全てのコメントを取り出してしまいます。最初は返信ではない記事へのコメントを取り出し、そこから各コメントへの返信を取り出したいのです。

なので、context['comment_list'] = self.object.comment_set.filter(parent__isnull=True)のようにして返信ではないコメントを取得しています。

コメントの作成ビューは長いように見えてシンプルで、GETならばコメント作成用フォームと記事モデルインスタンスをテンプレートに渡しますし、POSTならばCommentを作成し、リダイレクトします。

def comment_create(request, post_pk):
    """記事へのコメント作成"""
    post = get_object_or_404(Post, pk=post_pk)
    form = CommentForm(request.POST or None)

    if request.method == 'POST':
        comment = form.save(commit=False)
        comment.post = post
        comment.save()
        return redirect('blog:post_detail', pk=post.pk)

    context = {
        'form': form,
        'post': post
    }
    return render(request, 'blog/comment_form.html', context)

コメントへの返信ビューも殆ど同じなのですが、どのコメントに紐づくか?の部分を指定する必要があります。

def reply_create(request, comment_pk):
    """コメントへの返信"""
    comment = get_object_or_404(Comment, pk=comment_pk)
    post = comment.post
    form = CommentForm(request.POST or None)

    if request.method == 'POST':
        reply = form.save(commit=False)
        reply.parent = comment  # どのコメントへの返信か
        reply.post = post
        reply.save()
        return redirect('blog:post_detail', pk=post.pk)

    context = {
        'form': form,
        'post': post,
        'comment': comment,
    }
    return render(request, 'blog/comment_form.html', context)

また、reply.post = postとして、属する記事も指定しておいたほうが管理は捗ります。

base.html

Bulmaのスターターテンプレートです。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <meta name="viewport" content="width=device-width, initial-scale=1">
    <title>Hello Bulma!</title>
    <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/bulma/0.7.2/css/bulma.min.css">
    <script defer src="https://use.fontawesome.com/releases/v5.3.1/js/all.js"></script>
</head>
<body>
{% block content %}{% endblock %}
</body>
</html>

post_list.html

記事の一覧を表示しています。

{% extends 'blog/base.html' %}

{% block content %}
    <section class="section">
        <div class="container">
            {% for post in post_list %}
                <a href="{% url 'blog:post_detail' post.pk %}">
                    <div class="box">
                        <p>{{ post.title }}</p>
                    </div>
                </a>
            {% endfor %}
        </div>
    </section>
{% endblock %}
comment_form.html
コメント・返信作成のテンプレートです。今回はフォームを適当に表示しています。

{% extends 'blog/base.html' %}

{% block content %}
    <section class="section">
        <div class="container">
            <form action="" method="POST">
                {{ form.as_p }}
                {% csrf_token %}
                <button type="submit">送信</button>
            </form>
        </div>
    </section>
{% endblock %}

post_detail.html

記事の詳細ページは少し複雑です。

{% extends 'blog/base.html' %}

{% block content %}
    <section class="section">
        <div class="container">
            <a href="{% url 'blog:post_list' %}">戻る</a>
            <hr>

            <h1 class="title">{{ post.title }}</h1>
            <div class="content">
                {{ post.text | linebreaks }}
            </div>
            <a href="{% url 'blog:comment_create' post.pk %}">コメントする</a>

            <hr>
            <h2 class="title is-5">コメント一覧</h2>
            {% for comment in comment_list %}
                <div class="box">
                    <p>{{ comment.text }}</p>
                    <a href="{% url 'blog:reply_create' comment.pk %}">返信する</a>
                    {% with reply_list=comment.comment_set.all %}
                        {% include 'blog/includes/reply.html' %}
                    {% endwith %}
                </div>
            {% endfor %}
        </div>
    </section>
{% endblock %}

重要なのは、Djangoのテンプレートにおいて、どうやって再帰的な処理をするかです。

それをしているのは次の部分です。

            {% for comment in comment_list %}
                <div class="box">
                    <p>{{ comment.text }}</p>
                    <a href="{% url 'blog:reply_create' comment.pk %}">返信する</a>
                    {% with reply_list=comment.comment_set.all %}
                        {% include 'blog/includes/reply.html' %}
                    {% endwith %}
                </div>
            {% endfor %}

comment_listは、PostDetailビューから渡された、返信ではない記事に直接行ったコメント達です。

各コメントに紐づいた返信の一覧はcomment.comment_set.allで取得できます。しかし無限に返信ができる仕様なので、ちょっと考える必要があります。こういった場合によくやるのは、includeを使ったテンプレートの読み込みです。

まずwithタグを使うことで、comment.comment_set.all...各コメントの返信一覧がreply_listという名前で使えるようになります。この状態で{% include 'blog/includes/reply.html' %}とします。

includes/reply.html

{% for reply in reply_list %}
    <div class="box">
        <p>{{ reply.text }}</p>
        <a href="{% url 'blog:reply_create' reply.pk %}">返信する</a>
        {% with reply_list=reply.comment_set.all %}
            {% include 'blog/includes/reply.html' %}
        {% endwith %}
    </div>
{% endfor %}

各コメントの返信一覧...comment.comment_set.allがreply_listという名前で渡されているので、{% for reply in reply_list %}でそれを1つずつ取り出すことができます。

更にwith...includeによって、取り出した返信への返信一覧がreply_listとして定義され、また同じテンプレートを読み込んで...という感じで、再帰的な処理ができます。