1-20. コメント投稿機能を付与する

今回のテーマは「コメント投稿機能を付与する」です。第一章もいよいよ最後です。まだまだ不完全な掲示板アプリではありますが、今回を終えると以下の機能を満たすウェブアプリになる予定です。

  • トピックの登録
  • トピックの一覧表示
  • トピックのカテゴリー毎の表示
  • コメントの一覧表示
  • コメントの追加

※本ページは1-19. カテゴリー毎のトピック一覧画面を作るまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


ここまで手を動かしてきた方はなんとなくDjangoというフレームワークを使う感覚が分かってきたのではないでしょうか?(細かい機能はまだ紹介しきれていません)今回はこれまでの内容を理解していれば難しくありません。Djangoでスピーディに実装する感覚を味わいましょう。

※本ページはカテゴリー毎のトピック一覧画面を作るまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。

コメント投稿用のフォーム作成

ユーザー入力画面といえばフォームの作成ですね。今回もモデルベースのフォームで大丈夫ですのでModelFormを継承して作っていきます。

thread/forms.py
(一部抜粋)


class CommentModelForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = [
            'user_name',
            'message',
        ]

    def __init__(self, *args, **kwargs):
        kwargs.setdefault('label_suffix', '')
        super().__init__(*args, **kwargs)
        self.fields['user_name'].widget.attrs['value'] = '名無し'

FormとModelFormで扱ったとおり__init__関数をオーバライドして各種設定をしています。

トピック詳細表示テンプレートの修正

では早速トピック詳細表示テンプレートであるthread/detail_topic.htmlをコメント表示・登録用に修正しましょう。
templates/thread/detail_topic.html



{% extends 'base/base.html' %}
{% block title %}トピック作成 - {{ block.super }}{% endblock %}
{% block content %}
<div class="ui grid stackable">
    <div class="eleven wide column">
        <div class="ui breadcrumb">
            <a href="{% url 'base:top' %}" class="section">TOP</a>
            <i class="right angle icon divider"></i>
            <a href="{% url 'thread:category' url_code=topic.category.url_code %}" class="section">{{topic.category.name}}</a>
            <i class="right angle icon divider"></i>
            <a class="active section">{{topic.title}}</a>
        </div>
        <div class="ui segment">
            <div class="content">
                <div class="header"><h3>{{topic.title}}</h3></div>
                <p>{{topic.user_name}} - {{topic.created}}</p>
                <div class="ui segment">
                    <p><pre>{{topic.message}}</pre></p>
                </div>
            </div>
        </div>
        <!--コメント表示-->
        <div class="ui segment">
            {% if comment_list %}
            {% for comment in comment_list %}
            <div class="ui segment secondary">
                <p>{{comment.no}}. {{comment.user_name}}<br>{{comment.created}}</p>
                {% if comment.pub_flg %}
                <p><pre&gt:{{comment.message}}</pre></p>
                {% else %}
                <p style="color: #aaa">このコメントは非表示となりました。</p>
                {% endif %}
            </div>
            {% endfor %}
            {% else %}
            <div class="ui warning message"><p>まだコメントはありません</p></div>
            {% endif %}
        </div>
        <!--//コメント表示-->
        <!--コメント投稿-->
        <h4>コメント投稿</h4>
        <div class="ui segment">
            <form class="ui form" action="" method="POST">
                {% csrf_token %}
                {{form.as_p}}
                <button class="ui button orange" type="submit">コメント投稿</button>
            </form>
        </div>
        <!--//コメント投稿-->
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

テンプレートの中で条件分岐をしています。1つ目はcomment_listの有無で分岐、2つ目はコメントのpub_flgによって表示を分岐しています。Djangoのテンプレートはループや分岐等のある程度ロジカルな操作が可能ですが、あくまで表示に関する部分に限り見せたいデータの整形はViewで行った方が良いと考えます。また、コメント投稿欄については{{form.as_p}}でHTML出力しました。

ビューの作成

では次にビューを作っていきます。TopicDetailViewをカスタマイズしても良いのですが、練習なので新たにクラスを作成しましょう。TopicAndCommentViewとします。(ダサい命名でスミマセン)この画面はトピックの詳細表示、コメントのリスト表示、コメントの作成が行われる画面です。コメントの投稿は確認画面は作りません。さて、クラスベースビューで実装する際、どのベースビューを継承するのが良いでしょうか?これが正解というのはないのですが、今回の場合だとFormViewを継承したクラスを作るのが手間が少ないかと思います。CreateViewを使わない理由は入力した情報だけでなく、他の情報も付与してオブジェクトを保存したいためです。これについては実際のコードを見たほうが早いと思います。ではthread/views.pyを見ていきましょう。


class TopicAndCommentView(FormView):
    template_name = 'thread/detail_topic.html'
    form_class = CommentModelForm
    
    def form_valid(self, form):
        comment = form.save(commit=False) #保存せずオブジェクト生成する
        comment.topic = Topic.objects.get(id=self.kwargs['pk'])
        comment.no = Comment.objects.filter(topic=self.kwargs['pk']).count() + 1
        comment.save()
        return super().form_valid(form)

    def get_success_url(self):
        return reverse_lazy('thread:topic', kwargs={'pk': self.kwargs['pk']})
    
    def get_context_data(self):
        ctx = super().get_context_data()
        ctx['topic'] = Topic.objects.get(id=self.kwargs['pk'])
        ctx['comment_list'] = Comment.objects.filter(
                topic_id=self.kwargs['pk']).order_by('no')
        return ctx

簡単に解説します。template_nameについてはもう説明不要かと思います。GETでアクセスされた場合に表示するテンプレート名ですね。この時テンプレートに渡すコンテキストがget_context_data関数で定義されています。ここでは表示するトピックとリスト表示するコメント一覧をDBから取得しています。’topic_id’は’topic__idの誤りではないのか?とお気づきになった方は鋭いです。外部キーのIDは例外扱いなのです。(参考;公式ドキュメント)get_success_url関数はフォームのデータ検証成功後のリダイレクト先のURLを定義しています。

さて、問題はform_valid関数の中身ですね。これまでの話であればform.save()メソッドを呼んで保存すれば良かったのですが、トピックとコメント番号は後から付与したいために、上記のような記述になっています。saveメソッドにcommit=Falseと引数を渡すことで、保存せずにオブジェクトだけを生成出来ます。このオブジェクトに情報を付与して改めて保存しています。よく使う手段なので覚えておくと便利ですよ。

thread/urls.pyも書き換えましょう。

thread/urls.py


from django.urls import path

from . import views
app_name = 'thread'

urlpatterns = [
    path('create_topic/', views.TopicCreateView.as_view(), name='create_topic'),
    path('<int:pk>/', views.TopicAndCommentView.as_view(), name='topic'),
    path('category/<str:url_code>/', views.CategoryView.as_view(), name='category'),
]

あらためてMVTモデルについて考える

コメントを投稿し、保存するという機能は満たしました。この処理このようにビューで保存処理をしている参考サイトはとても多くて、おそらくこのように書く場合も多いのだと思います。しかし、改めて考えてみるとcommentオブジェクトの保存処理はビューの仕事ではありません。あくまで「ユーザーからこんな情報でコメント作成するって依頼が来ましたよ」とモデルに伝えて、その結果として「ユーザーに見せる情報」を返すことが(Djangoの)ビューの仕事です。なので、commentの保存処理はモデルにお任せしましょう。今回のようにフォームから受けた情報の保存処理はモデルのマネージャに書くか、フォームに書くことが多いです。フォームがモデルとは意外かも知れません。Djangoにおけるフォームの役割はMVTを横断しているため分かりづらいのですが、特にModelFormはモデルとしての役割が強いです。今回はCommentオブジェクトの保存処理はCommentModelForm内で処理することにします。もちろん、CommentManager内で書いても良いと思います。(話がDjangoとは逸れますが、MVCモデルでもビューやコントローラにビジネスロジックを書きまくる初心者の方が意外に多い気がします…)

thread/forms.py(一部抜粋)


class CommentModelForm(forms.ModelForm):
    class Meta:
        model = Comment
        fields = [
            'user_name',
            'message',
            'image',
        ]

    def __init__(self, *args, **kwargs):
        kwargs.setdefault('label_suffix', '')
        super().__init__(*args, **kwargs)
        self.fields['user_name'].widget.attrs['value'] = '名無し'

    def save_with_topic(self, topic_id, commit=True):
        comment = self.save(commit=False)
        comment.topic = Topic.objects.get(id=topic_id)
        comment.no = Comment.objects.filter(topic_id=topic_id).count() + 1
        if commit:
            comment.save()
        return comment

CommentModelFormクラスにsave_with_topic関数を定義しました。トピックIDを引数にとりコメントを保存します。ビューからはこの処理をメソッドとして呼び出すように書き換えましょう。

thread/views.py(一部抜粋)


class TopicAndCommentView(FormView):
    template_name = 'thread/detail_topic.html'
    form_class = CommentModelForm
    
    def form_valid(self, form):
        # comment = form.save(commit=False)
        # comment.topic = Topic.objects.get(id=self.kwargs['pk'])
        # comment.no = Comment.objects.filter(topic=self.kwargs['pk']).count() + 1
        # comment.save()
        # コメント保存のためsave_with_topicメソッドを呼ぶ
        forms.save_with_topic(self.kwargs.get('pk'))
        return super().form_valid(form)

    def get_success_url(self):
        return reverse_lazy('thread:topic', kwargs={'pk': self.kwargs['pk']})
    
    def get_context_data(self):
        ctx = super().get_context_data()
        ctx['topic'] = Topic.objects.get(id=self.kwargs['pk'])
        ctx['comment_list'] = Comment.objects.filter(
                topic_id=self.kwargs['pk']).order_by('no')
        return ctx

これでビューは本来の仕事に専念できますね。「どうやってcommentを保存するか」はモデルにお任せすべきと考えます。

第一章の終わりに

お疲れ様でした。ここまで手を動かしてコードを書いてきた方はDjangoの使い方が何となく見えてきたでしょうか?見えてくると良いなと思っています。作ってきたサンプルアプリの確認をしてみましょう。

コメント投稿前の画面

コメント投稿後の画面

これで、取り敢えず「掲示板」と呼べそうなものが出来上がってきました。サイドバーの表示がまだダミーのままですが、その処理も第二章で扱います。まだまだお伝えしたいことはありますが第一章に関しては筆を置きたいと思います。しばらく鋭気を養ったら第二章を書き始めます。どうぞ宜しくお願いいたします。

おまけ:サンプルアプリの補足

この章の終わりにもう少し頑張って、途切れているリンクの修正や静的なページを追加しちゃいましょう。面倒な方は作業不要です。追加ページは「プライバシーポリシー」、「このサイトについて」です。リンクやCSSを一部修正したバージョンを下記しておきます。Djangoを解説するという本筋からは逸れるので本文では放置してきた部分です。

templates/base/base.html



{% load static %}
<!DOCTYPE html>
<head>
    <meta charset="UTF-8">
    <meta http-equiv="content-language" content="ja">
    <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
    {% block meta_tag %}{% endblock %}
    <link href="{% static 'css/semantic.css' %}" rel="stylesheet">
    {% block css %}{% endblock %}
    <title>
        {% block title %}IT学習ちゃんねる{% endblock %}
    </title>
</head>
<body>
    <div class="ui stackable inverted menu">
        <a href="{% url 'base:top' %}" class="header item">
            IT学習ちゃんねる
        </a>
        <a href="{% url 'base:about' %}" class="item">
            このサイトはなに?
        </a>
        <a class="item" href="{% url 'thread:create_topic' %}">
            トピック作成
        </a>
        <div class="right menu">
            <a class="item">
                Log in
            </a>
            <a class="item">
                Sign up
            </a>
        </div>
    </div>
    
    <div class="ui container" style="min-height:100vh;">
        {% block content %}
        {% endblock %}
    </div>
    <div class="ui inverted stackable footer segment">
        <div class="ui container center aligned">
            <div class="ui horizontal inverted small divided link list">
                <a href="{% url 'base:top' %}" class="item">© 2019 IT学習ちゃんねる(仮)</a>
                <a href="{% url 'base:terms' %}" class="item">利用規約</a>
                <a href="{% url 'base:policy' %}" class="item">プライバシーポリシー</a>
            </div>
        </div>
    </div>
    <script src="https://code.jquery.com/jquery-3.1.1.min.js"></script>
    <script type="text/javascript" src="{% static 'js/semantic.js' %}"></script>
    {% block js %}{% endblock %}
</body>

templates/base/sidebar.html



<div class="five wide column">
    <div class="ui action input" style="width: 100%;">
        <input type="text" placeholder="検索">
        <button class="ui button"><i class="search icon"></i></button>
    </div>
    <div class="ui items">
        <div class="item">
            <a href="{% url 'thread:create_topic' %}" class="ui fluid teal button">トピックを作成</a>
        </div>
    </div>
    <div class="ui segment">
        <div class="content">
            <div class="header"><h4>カテゴリー</h4></div>
            <div class="ui relaxed list small divided link">
                <a class="item">dummy</a>
                <a class="item">dummy</a>
                <a class="item">dummy</a>
                <a class="item">dummy</a>
                <a class="item">dummy</a>
            </div>
        </div>
    </div>
</div>

templates/base/policy.html



{% extends 'base/base.html' %}
{% block title %}プライバシーポリシー - {{ block.super }}{% endblock %}
{% block content %}
<div class="ui grid stackable">
    <div class="eleven wide column">
        <div class="ui breadcrumb">
            <a href="{% url 'base:top' %}" class="section">TOP</a>
            <i class="right angle icon divider"></i>
            <a class="active section">プライバシーポリシー</a>
        </div>
        <div class="ui segment">
            <div class="content">
                <div class="header"><h3>プライバシーポリシー</h3></div>
                <p>あんなことやこんなことに情報を使います。などなど</p>
                <p>...............</p>
                <p>...........</p>
                <p>.......</p>
            </div>
        </div>
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

templates/base/about.html



{% extends 'base/base.html' %}
{% block title %}このサイトはなに? - {{ block.super }}{% endblock %}
{% block content %}
<div class="ui grid stackable">
    <div class="eleven wide column">
        <div class="ui breadcrumb">
            <a href="{% url 'base:top' %}" class="section">TOP</a>
            <i class="right angle icon divider"></i>
            <a class="active section">このサイトはなに?</a>
        </div>
        <div class="ui segment">
            <div class="content">
                <div class="header"><h3>このサイトはなに?</h3></div>
                <p>このサイトはDjangoのサンプルアプリです。などなど</p>
                <p>...............</p>
                <p>...........</p>
                <p>.......</p>
            </div>
        </div>
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

base/urls.py


from django.urls import path
from django.views.generic import TemplateView
from . import views

app_name = 'base'

urlpatterns = [
    path('', views.TopicListView.as_view(), name='top'),
    # path('', views.top, name='top'),
    path('terms/', TemplateView.as_view(template_name='base/terms.html'), name='terms'),
    path('policy/', TemplateView.as_view(template_name='base/policy.html'), name='policy'),
    path('about/', TemplateView.as_view(template_name='base/about.html'), name='about'),
]

Sponsored Link


「1-20. コメント投稿機能を付与する」への3件のフィードバック

  1. コメント失礼いたします
    thread/views.pyの
    forms.save_with_topic(self.kwargs.get(‘pk’))は正しくは
    form.save_with_topic(self.kwargs.get(‘pk’))ではないでしょうか。

  2. コメント失礼いたします。
    大変丁寧な解説でいつも助かっております。

    今回のコメント機能を実装するセッションで、オレンジ色のコメント投稿ボタンは表示されるのですが、コメント者の名前と内容を入力する場所が表示されません。

    解決するにはどうすれば良いのでしょうか。

    1. すみません、解決しました!
      pathを変更するのを忘れていました。
      しかし、コメントを投稿する際に改行した内容が、コメントを表示する際に改行されず困っています。
      また、トピックの本文を表示する際にも、長文を改行無しで投稿すると、横1列で表示され、画面に収まらないことがあります。組み込みタグで画面の大きさや、文字数制限に合わせて改行できるようなものがあったら教えてください。

コメントは受け付けていません。