2-7. サイトマップを作成する

今回のテーマは「サイトマップを自動作成する」です。SEO対策として検索エンジンにウェブサイトの更新を通知するためサイトマップを用意したいケースも多いと思います。今回はDjangoを用いてサイトマップを動的に生成する方法を見ていきます。

今回の内容は公式ドキュメントではThe sitemap frameworkの部分に相当します。

※本ページはページネーションを使うまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


準備

まずsettings.pyを変更する必要があります。
mysite/settings.py


  INSTALLED_APPS = [
      'django.contrib.admin',
      'django.contrib.auth',
      'django.contrib.contenttypes',
      'django.contrib.sessions',
      'django.contrib.messages',
      'django.contrib.staticfiles',
+     'django.contrib.sites',
+     'django.contrib.sitemaps',
      'debug_toolbar',
      'base',
      'thread',
      'api',
      'search',
  ]
  
+ SITE_ID = 1

次にデータベースのマイグレーションをします。


(venv)$ ./manage.py migrate

管理画面にアクセスしてドメイン名と表示名を変更します。今回はmydomain.comに修正しました。このサイトオブジェクトのIDが先程settings.pyで設定したSITE_IDと一致している必要があります。

Sitemap継承クラスを作る

ではサイトマップを作っていきましょう。DjangoにおいてサイトマップはSitemapクラスを継承したクラスにて作成します。thread/sitemaps.pyを作成します。尚、sitemaps.pyはDjangoで決まっているファイルではありませんので別の名前でもいいですし、他のファイルに書いても構いません。

thread/sitemaps.py


from django.contrib.sitemaps import Sitemap
from django.shortcuts import resolve_url

from . models import Topic, Category

class TopicSitemap(Sitemap):
    priority = 0.5
    changefreq = 'always'
    
    def items(self):
        return Topic.objects.all()

    def location(self, obj):
        return resolve_url('thread:topic', pk=obj.id)
    
class CategorySitemap(Sitemap):
    priority = 0.5
    changefreq = 'never'

    def items(self):
        return Category.objects.all()

    def location(self, obj):
        return resolve_url('thread:category', url_code=obj.url_code)

Sitemapクラスを継承したクラスを作成しています。このクラスではサイトマップを作成に必要な情報をクラスの属性として与えます。この際、静的な情報はクラス変数、動的な情報は関数を使って指定出来るようになっています。

指定できる属性は以下です。

items [必須]ページのオブジェクト。イテレータブルなオブジェクトを指定する
location [オプション]itemsのURL
lastmod [オプション]ページ更新日時
changefreq [オプション]ページの更新頻度
priority [オプション]ページの重要度0〜1
limit [オプション]ページネーションの区切り。デフォルトは50000
protocol [オプション]httpプロトコル。デフォルトはhttp
i18n [オプション]URL表示に設定言語を適用するか True/False

まず、items()メソッドでトピックのリストを与えています。この関数が返すイテレータブルなitemsの1つ1つに対してサイトマップが生成されます。ではこのitemsの要素であるitemのURLのを指定するためにlocation関数でURLを指定しましょう。トピックのURLは動的に変更するのでlocationは関数で与えます。このようにitemsで指定したリストの1要素を仮引数とする関数を作ることで動的なサイトマップを生成できます。

カテゴリー用のサイトマップも同様です。CategorySitemapクラスを作成して要素を指定しています。カテゴリーの場合はurl_codeを指定することに注意して下さい。

では、次に静的なページである利用規約やプライバシーポリシーについて見ていきましょう。これらのページは動的要素がないために非常に簡単です。base/sitemaps.pyを生成しましょう。

base/sitemaps.py


from django.contrib.sitemaps import Sitemap
from django.shortcuts import resolve_url

class BaseSitemap(Sitemap):

    def items(self):
        items = [
            'base:top',
            'base:policy',
            'base:terms',
        ]
        return items

    def location(self, obj):
        return resolve_url(obj)

    def changefreq(self, obj):
        if obj == 'base:top':
            return 'always'
        return 'never'

    def priority(self, obj):
        if obj == 'base:top':
            return 0.8
        return 0.1

基本的な考え方はthread/sitemaps.pyと同じです。ただitemsで’base:policy’のようにページのショートカット名でリストを生成することで効率的にサイトマップを生成することができます。上記でクラス変数として指定した属性についても関数で指定しています。両者を比べると理解がより進むのではないでしょうか。

URLの設定

では、作成したサイトマップを表示してみましょう。ここから先はmysite/urls.pyを変更していきます。
mysite/urls.py


  from django.contrib import admin, auth
  from django.urls import path, include
  from django.conf import settings
  from django.contrib.sitemaps.views import sitemap
  
+ from thread.sitemaps import TopicSitemap, CategorySitemap
+ from base.sitemaps import BaseSitemap
  
+ sitemaps = {
+     'topic': TopicSitemap,
+     'cateogry': CategorySitemap,
+     'base': BaseSitemap,
+ }
  
  urlpatterns = [
      path('admin/', admin.site.urls),
      path('accounts/', include('django.contrib.auth.urls')),
      path('', include('base.urls')),
      path('thread/', include('thread.urls')),
      path('api/', include('api.urls')),
      path('search/', include('search.urls')),
+     path('sitemap.xml', sitemap, {'sitemaps': sitemaps}),
  ]
  
  if settings.DEBUG:
      import debug_toolbar
      urlpatterns = [
          path('__debug__/', include(debug_toolbar.urls)),
      ] + urlpatterns

このように各アプリケーションでSitemap継承クラスを作成し、urls.pyでまとめるというのはDjangoのルールではないですが、分かりやすいので筆者は気に入っています。

では確認してみましょう。localhost:8080/sitemap.xmlにアクセスします。

最後に

Djangoのサイトマップ生成機能を使うと簡単に動的なサイトマップを作成することができますね。今回は基礎的な部分に着目したので、テンプレートをカスタマイズしたいという場合には公式ドキュメントを呼んでみて下さい。

Sponsored Link


2-6. ページネーションを使う

今回のテーマは「ページネーションを使う」です。今回はWEBアプリでつきもののページネーションについて触れていきます。Djangoでは標準で手軽に使えるページネーション機能が組み込まれています。

※本ページは検索画面を作るまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


クラスベースビューでページネーションを使う

ページネーションは関数ベースでもクラスベースビューでも使えるのですが、クラスベースビューで使用したほうが楽なので、まずそちらから紹介します。手軽なのはListViewを継承する方法です。ちょうど、thread/views.pyのCategoryViewクラスがListViewを継承したクラスですのでこれにページネーションを適用することを考えてみましょう。

まずページネーションを表示するためのテンプレートを用意しましょう。templates/base/pagination.htmlを作成します。

templates/base/pagination.html



{% if is_paginated %}
<div class="ui basic segment center aligned">
    <div class="ui pagination menu">
    <!--左矢印-->
    {% if page_obj.has_previous %}
        <a class="item" href="?p={{page_obj.previous_page_number}}"><i class="chevron left icon"></i></a>
    {% else %}
        <a class="disabled item"><i class="chevron left icon"></i></a>
    {% endif %}
    <!--//左矢印-->
    <!--ページ番号-->
    {% for link_page in page_obj.paginator.page_range %}
        {% if link_page == page_obj.number %}
            <a class="disabled item">{{link_page}}</a>
        {% else %}
            <a class="item" href="?p={{link_page}}">{{link_page}}</a>
        {% endif %}
    {% endfor %}
    <!--//ページ番号-->
    <!--右矢印-->
    {% if page_obj.has_next %}
        <a class="item" href="?p={{page_obj.next_page_number}}"><i class="chevron right icon"></i></a>
    {% else %}
        <a class="disabled item"><i class="chevron right icon"></i></a>
    {% endif %}
    <!--//右矢印-->
    </div>
</div>
{% endif %}
        

テンプレートに渡されたpage_objというパラメータで組み立てていきます。page_objはその名のとおりPageオブジェクトです。pageオブジェクトはpaginatorから生成されますが、ページングに関する様々な情報を備えています。詳細は公式ドキュメントのページネーションを一読することをオススメします。pageオブジェクトはインスタンス変数としてページ番号、pagenatorオブジェクト、オブジェクトリストを保有しており、この情報を基に、次にページがあるか、ないかなど情報を取得するインターフェースを備えています。今回はpageオブジェクトのインスタンス変数であるpagenateオブジェクトのpage_rangeを呼び出してループしています。

※実はListViewを使用した場合はpaginatorオブジェクトは別に渡されているので、そちらを使っても構わないのですが、次に扱う関数ベースのビューの場合のため今回はpageオブジェクトから情報を全て取得します。

この段階ではまだ良くわからないと思います。後半の関数ベースでの処理まで見て再度見直すとテンプレートの意味が何となく見えてくると思います。

次にこのpagination.htmlをcategory.htmlでインクルードしましょう。

templates/thread/category.html(一部抜粋)



  <div class="ui segment">
      <div class="content">
          <div class="header"><h3>{{category.name}}</h3></div>
+         {% include 'base/pagination.html' %}
          <div class="ui divided items">
              {% if topic_list %}
              {% for topic in topic_list %}

単順に見出しの下にインクルード下だけです。

ではthread/views.pyのCategoryViewを修正しましょう。
thread/views.py(一部抜粋)


  class CategoryView(ListView):
      template_name = 'thread/category.html'
      context_object_name = 'topic_list'
+     paginate_by = 1 # 1ページに表示するオブジェクト数 サンプルのため1にしています。
+     page_kwarg = 'p' # GETでページ数を受けるパラメータ名。指定しないと'page'がデフォルト
  
      def get_queryset(self):
          return Topic.objects.filter(category__url_code = self.kwargs['url_code'])
       
      def get_context_data(self):
          ctx = super().get_context_data()
          ctx['category'] = get_object_or_404(Category, url_code=self.kwargs['url_code'])
          return ctx

修正はこれだけです。実はListViewはBaseListViewを継承したクラスなのですが、このクラスはMultipleObjectMixinという複数のオブジェクトを表示する機能を持ったクラスを継承しており、MultipleObjectMixinがページネーション機能を有しているためクラス変数を指定するだけで使えたのです。尚、paginate_byには1ページに表示するオブジェクトの数、page_kwargsはGETで受けるページのパラメータ名でありデフォルトは’page’です。自作のクラスベースビューにListViewのようなページネーション機能を持たせる場合にはMultipleObjectMixinを継承させると機能を付与することができます。

では、表示して見ましょう。

関数ベースのビューでページネーションを使う

クラスベースビューでは予め用意されたクラス変数をオーバライドすれば良かったので楽でした。ただし何が行われているのかが見えづらくて分かりづらい部分もあったと思います。そこで関数ベースのビューで同じ挙動をするビューを作成してみることにします。先程はMultipleObjectMixinが自動で行っていた部分を自分で書いていきます。

thread/views.py(一部抜粋)


from django.core.paginator import Paginator, PageNotAnInteger, EmptyPage
def show_catgegory(request, url_code):
    if request.method == 'GET':
        page_num = request.GET.get('p', 1)
        pagenator = Paginator(
            Topic.objects.filter(category__url_code=url_code),
            1 # 1ページに表示するオブジェクト数
        )
        try:
            page = pagenator.page(page_num)
        except PageNotAnInteger:
            page = pagenator.page(1)
        except EmptyPage:
            page = pagenator.page(pagenator.num_pages)

        ctx = {
            'category': get_object_or_404(Category, url_code=url_code),
            'page_obj': page,
            'topic_list': page.object_list, # pageでもOK
            'is_paginated': page.has_other_pages,
        }
        return render(request, 'thread/category.html', ctx)

  urlpatterns = [
      path('create_topic/', views.TopicCreateView.as_view(), name='create_topic'),
      path('/', views.TopicViewAndCommentCreateView.as_view(), name='topic'),
-     path('category//', views.CategoryView.as_view(), name='category'),
+     path('category//', views.show_catgegory, name='category'),
  ]

thread/urls.pyも修正します。

見た目は全く変わらないので画像は省略します。恐らくこちらの方が分かりやすいという方が多いと思います。先程見たListViewの継承クラスではpage_obj, is_paginatedは自動で渡されていたのです。また、topic_listも区切りの数字に併せて調整されていましたが、これもpageのobject_listを渡すことで対応しています。

最後に

クラスベースビューはコード量が少なく自動でいろいろやってくれる分、フレームワークで何をやっているのかが分かりづらい面もあります。時にはDjangoのソースを追って関数ベースビューで書き直してみてみると勉強にもなるんじゃないかな・・・と思っていますが忙しい方には大変ですね。このサイトがお役に立てるよう頑張ります。次は心機一転でサイトマップを作ってみます。

Sponsored Link


2-5. 検索画面を作る

今回のテーマは「検索画面を作る」です。今回はDjangoの便利機能という訳ではないんですが、クエリセットの扱いについて紹介できればと思います。

※本ページはDjangoのAPIとAjax通信する「いいねボタン」を作成するまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


検索アプリケーションの作成

まずは検索を担当するアプリケーションを追加しましょう。Djangoは機能別のアプリケーションを集めてWebアプリケーションを構築します。もちろん、threadアプリケーション内に検索機能を持たせることもできますが、将来別のアプリケーションが追加された場合にはサイト全体の検索を誰が担うのか所在がぼやける恐れがあります。検索を専門に行うsearchアプリケーションを作ることにします。


(venv)$ ./manage.py startapp search

アプリケーションを追加したら、いつもどおりmysite/settings.pyとmysite/urls.pyを変更します。

mysite/settings.py


(venv)$ ./manage.py startapp search

mysite/urls.py


(venv)$ ./manage.py startapp search

テンプレートの作成

ではテンプレートを作っていきましょう。検索結果を表示するテンプレートはこのようになります。

templates/search/result.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>
                <form action="{% url 'search:result' %}" method="GET">
                    <div class="ui action input" style="width: 100%;">
                        <input type="text" placeholder="検索" value="{{query}}" name="q">
                        <button class="ui button"><i class="search icon"></i></button>
                    </div>               
                </form>
                {% if result_list %}
                {% for result in result_list %}
                <div class="ui segment message">
                    <h3><a href="{% url 'thread:topic' pk=result.id %}">{{result.title}}</a></h3>
                    <p>{{result.message | truncatewords:30}}</p>
                </div>
                {% endfor %}
                {% else %}
                <div class="ui segment warning message">
                    <p>検索結果はありません。</p>
                </div>
                {% endif %}
            </div>
        </div>
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

特に問題ないと思います。今回はGETでアクセスするのでcsrfについては気にしなくてOKです。

ビューの作成

次にビューを作ります。ビューにはthreadアプリケーションのモデルをインポートします。
search/views.py


from django.shortcuts import render
from django.db.models import Q
from django.views.generic import ListView
from functools import reduce
from operator import and_
from thread.models import Topic

class SearchResultView(ListView):
    template_name = 'search/result.html'
    context_object_name = 'result_list'

    def get_queryset(self):
        if self.request.GET.get('q', ''):
            params = self.parse_search_params(self.request.GET['q'])
            query = reduce(
                lambda x,y : x & y,
                list(map(lambda z: Q(title__icontains=z) | Q(message__icontains=z), params))
            )
            # 下記でもOK
            # query = reduce(and_, [Q(title__icontains=p) | Q(message__icontains=p) for p in params])
            return Topic.objects.filter(query)
        else:
            return None
    
    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['query'] = self.request.GET.get('q', '')
        return ctx
        
    def parse_search_params(self, words: str):
        search_words = words.replace(' ', ' ').split()
        return search_words

今回はQオブジェクトが出てきました。Djangoでは複雑なクエリセットを組み立てる時にはQオブジェクトを使用します。Qオブジェクトを使用すると”|”演算子でOR条件、”&”演算子でAND条件を表現できます。Qオブジェクトはビット演算子によって新たなQオブジェクトを生成します。よってQ(title__icontains=z)|Q(message_icontains=z)は1つのQオブジェクトとなります。icontainsでは大文字、小文字を区別せず検索します。

それを念頭に置いた上で上記のソースコードを見ると分かりやすいと思います。見やすいようにlambdaとmapで書きましたが、and_関数とリスト内包表記でも同じことができます。

このビューにアクセスするURLを作成します。
search/urls.py


from django.urls import path
from . import views

app_name = 'search'

urlpatterns = [
    path('', views.SearchResultView.as_view(), name='result'),
]

サイドバーの修正

次にサイドバーの検索バーから検索できるようにtemplates/base/sidebar.htmlを修正しましょう。
templates/base/sidebar.html



{% load threadtags %}
<div class="five wide column">
    <form action="{% url 'search:result' %}" method="GET">
        <div class="ui action input" style="width: 100%;">
            <input type="text" placeholder="検索" name="q">
            <button type="submit" class="ui button"><i class="search icon"></i></button>
        </div>
    </form> 
    <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>
            {% category_tag %}
        </div>
    </div>
</div>

では確認してみましょう。 サイドバーの検索窓から検索を行って検索結果画面に検索結果が表示されればOKです。

検索窓にキーワード入力

検索結果画面

最後に

今回のポイントはQオブジェクトの使い方ですね。ビット演算子で連結することで複雑なクエリを表現できますので、活用してみて下さい。次回はページネーションを扱っていきます。だんだん掲示板っぽくなってきましたね。

Sponsored Link


2-4. DjangoのAPIとAjax通信する「いいねボタン」を作成する

今回のテーマは「DjangoのAPIとAjax通信する「いいねボタン」を作成する」です。今回はDjangoで作られたAPIにjavascriptでAjax通信する処理を見ていきたいと思います。APIの設計等は本題から外れるためお粗末だとは思いますが、気になる点があればコメントいただければと思います。

※本ページはテンプレートのフィルターを使うまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


いいねボタンを作るための準備

いいねボタンがどんなものかは説明不要だと思いますが、想定を簡単に説明しておきます。ユーザーがいいねボタンを押すと1POINT加点されリアルタイムで数字が変化します。連打防止はjavascriptおよびIPアドレスで判定します。

これを実装するにあたりvoteモデルを用意します。
thread/models.py(一部抜粋)


class VoteManager(models.Manager):
    def create_vote(self, ip_address, comment_id):
        vote = self.model(
            ip_address=ip_address,
            comment_id = comment_id
        )
        try:
            vote.save()
        except:
            return False
        return True

class Vote(models.Model):
    comment = models.ForeignKey(
        Comment,
        on_delete=models.CASCADE,
        null=True,
    )
    ip_address = models.CharField(
        'IPアドレス',
        max_length=50,
    )

    objects = VoteManager()

    def __str__(self):
        return '{}-{}'.format(self.comment.topic.title, self.comment.no)

ここはモデル作成の部分で説明済みですので特に問題ないと思います。モデルの準備が出来たらマイグレーションをしましょう。もう大丈夫ですね。


(venv)$ ./manage.py makemigrations
(venv)$ ./manage.py migrate

voteの数を数えるようにthread/views.pyを変更しましょう。
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()
          Comment.objects.create_comment(
              user_name=form.cleaned_data['user_name'],
              message=form.cleaned_data['message'],
              topic_id=self.kwargs['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')
+         ctx['comment_list'] = Comment.objects.filter(
+                 topic_id=self.kwargs['pk']).annotate(vote_count=Count('vote')).order_by('no')
          return ctx

特に説明は不要かと思います。annotateで各コメントが参照しているVoteモデルの数を数えて追加しています。このvote_countが投票されたポイントとなります。

テンプレート側の準備

いいねボタンのUIを準備しましょう。templates/thread/detail_topic.htmlを修正します。
templates/thread/detail_topic.html(一部抜粋)



  {% if comment.pub_flg %}
+ <p>{{comment.message | comment_filter | safe}}</p>
+ <div class="ui right aligned vertical segment">
+     <div class="vote_button" style="cursor: pointer;"
+         data-comment-id="{{comment.id}}" data-count="{{comment.vote_count}}">
+         <i class="heart outline icon"></i>
+         <span class="vote_counter">
+             {% if comment.vote_count > 0 %}{{comment.vote_count}}{% endif %}
+         </span>
+     </div>
+ </div>
+ {% else %}
+ <p style="color: grey">コメントは非表示とされました</p>
  {% endif %}

こんなハートマークが表示されると思います。

APIの作成

さて、これで事前準備ができました。API用のアプリケーションを用意しましょう。


(venv)$ ./manage.py startapp api

アプリケーションを追加したのでmysite/settings.py, mysite/urls.pyをいつもどおり設定します。

mysite/settings.py


  INSTALLED_APPS = [
      'django.contrib.admin',
      'django.contrib.auth',
      'django.contrib.contenttypes',
      'django.contrib.sessions',
      'django.contrib.messages',
      'django.contrib.staticfiles',
      'debug_toolbar',
      'base',
      'thread',
+     'api',
  ]

mysite/uls.py


  urlpatterns = [
      path('admin/', admin.site.urls),
      path('accounts/', include('django.contrib.auth.urls')),
      path('', include('base.urls')),
      path('thread/', include('thread.urls')),
+     path('api/', include('api.urls')),
  ]

ここまでは問題ないですね。ではAPIを作成していきましょう。
api/views.pyを以下のようにします。
api/views.py


from django.views.generic import View
from django.http import JsonResponse

from thread.models import Vote

class CreateVoteView(View):
    '''
    いいね投票作成処理を行う
    '''
    def post(self, request, *args, **kwargs):
        res = {
            'result': False,
            'message': '処理に失敗しました。'
        }
        # POST値に'comment_id'がなければBAD REQUESTとする
        if not 'comment_id' in request.POST:
            return JsonResponse(res, status=400)
        
        # コメントIDとIPアドレスの取得
        comment_id = request.POST['comment_id']
        ip_address = get_client_ip(request)

        # 既にIP登録があればコンフリクト
        if Vote.objects.filter(comment_id=comment_id, ip_address=ip_address):
            res['message'] = '投票済みです'
            return JsonResponse(res, status=409)
        
        # Voteの保存に成功した場合のみ成功
        if Vote.objects.create_vote(ip_address, comment_id):
            res['result'] = True
            res['message'] = 'ポイント追加しました'
            return JsonResponse(res, status=201)      
        else:
            return JsonResponse(res, status=500)

def get_client_ip(request):
    '''
    IPアドレスを取得する
    '''
    x_forwarded_for = request.META.get('HTTP_X_FORWARDED_FOR')
    if x_forwarded_for:
        ip = x_forwarded_for.split(',')[0]
    else:
        ip = request.META.get('REMOTE_ADDR')
    return ip

ソースを追っていただければ処理自体は非常に簡単だと思います。コメントIDとIPアドレスをセットで格納し、新たな投票時にはIPアドレスによって重複投票を防いでいます。今回はViewを継承したクラスベースビューを用意しました。関数タイプで書いても問題ないですよ。今回はテンプレートがないですが、ユーザーに見せるべき情報を用意するというビューの役割は変わりません。

JSON形式でレスポンスを返すためにJsonResponseを用いています。JsonResponseは辞書型の変数を引数としてJSON形式にエンコードしたボディを返します。また、HTTPステータスコードをstatus引数で渡しています。API設計の拙い点は目をつぶっていただければと思います。また、一般的に、HTTPレスポンスコード201の場合はヘッダに作成したオブジェクトを示すURLを入れることが推奨されていますが、今回は投票ということで結果のみを返しています。

このビューにアクセスするためのURLを設定します。api/urls.pyを生成します。

api/urls.py


from django.urls import path
from . import views
# from django.views.decorators.csrf import csrf_exempt

app_name = 'api'

urlpatterns = [
    path('v1/vote/', views.CreateVoteView.as_view(), name='create_vote'),
]

これでAPI側の準備はできました。

javascriptの実装を見てみましょう。今回はjQueryフレームワークを使用して実装します。使いたくない方は生のjavascriptでも問題ありません。static/js/vote.jsを用意しましょう。
static/js/vote.js


$(function(){
    // setup for ajax
    var csrftoken = getCookie('csrftoken');
    $.ajaxSetup({
        beforeSend: function(xhr, settings) {
            if (!csrfSafeMethod(settings.type) && !this.crossDomain) {
                xhr.setRequestHeader("X-CSRFToken", csrftoken);
            }
        }
    });

    var votedList = [];// 連打防止用のコメントID格納リスト
    // いいねボタン押下時の処理
    onClickVoteButton();

    function getCookie(name) {
        var cookieValue = null;
        if (document.cookie && document.cookie !== '') {
            var cookies = document.cookie.split(';');
            for (var i = 0; i < cookies.length; i++) {
                var cookie = jQuery.trim(cookies[i]);
                // Does this cookie string begin with the name we want?
                if (cookie.substring(0, name.length + 1) === (name + '=')) {
                    cookieValue = decodeURIComponent(cookie.substring(name.length + 1));
                    break;
                }
            }
        }
        return cookieValue;
    }

    function csrfSafeMethod(method) {
        // these HTTP methods do not require CSRF protection
        return (/^(GET|HEAD|OPTIONS|TRACE)$/.test(method));
    }

    function onClickVoteButton() {
        $('.vote_button').on('click', function() {
            var commentId = $(this).data('comment-id');
            var currentCount = $(this).data('count');
            var countViewer = $(this).find('.vote_counter');
            if (votedList.indexOf(commentId) < 0) {
                vote(commentId, currentCount, countViewer);
            }
        });
    }

    // ajax通信して投票結果を反映する
    function vote(commentId, currentCount, countViewer) {
        let url = '/api/v1/vote/';
        $.ajax({
            type: 'POST',
            url: url,
            data: {
                comment_id: commentId
            }
        }).then(
            data => {
                if (data.result) {
                    countViewer.text(currentCount + 1);
                    votedList.push(commentId);
                }
            },
            error => {
                if (error.responseJSON.message) {
                    alert(error.responseJSON.message);
                }
            }
        );
    }
});

POSTメソッドでAPIにアクセスする場合は注意が必要です。DjangoのPOSTメソッドにはクロススクリプトフォージェリ対策が必要であるためにキャッシュからトークンを取得してヘッダにセットする処理をしています。これさえ注意すれば後は特に問題ありません。Ajaxの通信結果に応じてUIに変更を加える処理をしています。尚、GETでアクセスする場合にはCSRFの認証を気にする必要はありません。

ではこのvote.jsが読み込まれるようにtemplates/thread/detail_topic.htmlに追加しましょう。

templates/thread/detail_topic.html(一部抜粋)


<--!ファイルの末尾に追加-->
{% block js %}
<script src="{% static 'js/vote.js' %}" type='text/javascript'></script>
{% endblock %}

これで、vote.jsが読み込まれるようになったはずです。ではブラウザで確認してみましょう。ハートマークを押してポイントがプラス1されればOKです。同一コメントに対しては同じIPからは一回しか投票できないはずです。

最後に

単にAjax通信を説明する割には大げさなサンプルになってしまいました。Ajax通信だけでなく、JSONレスポンスを返す場合の処理についても紹介できたのではないかと思います。尚、csrfを解除してPOSTしたいという要望もあると思います。これに関しては別の機会に扱おうと思います。

Sponsored Link


2-3. テンプレートのフィルターを使う

今回のテーマは「テンプレートのフィルターを使う」です。前回テンプレートタグを作成しました。今回はテンプレート上で用いる独自フィルターを使用していきます。

※本ページはテンプレートタグを使ってサイドバーを作成するまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


Djangoテンプレートのフィルター

Djangoのテンプレートには他のフレームワーク同様にテンプレート上でフィルター処理を行えます。そのため、予め組み込まれた組み込みフィルターが用意されています。組み込みフィルターの種類については公式ドキュメントの組み込みタグとフィルタをご覧ください。例えば、truncatecharsという組込みフィルターがありますが、これは{{ context.hoge | truncatechars:10 }}と書くと10文字に切り詰めて表示するという機能を持つフィルターです。

カスタムフィルターを作る

ではカスタムフィルターを作成していきましょう。今回はコメントにURLが記載された場合リンクとして表示するフィルターを作成しましょう。thread/templatetags/threadfilters.pyを作成します。

thread/templatetags/threadfilters.py


from django.template import Library

register = Library()

@register.filter
def comment_filter(text):
    return '
'.join(list(map(convert_url, text.split('\n')))) def convert_url(text_line): ''' URLリンク行をaタグ付きの行に変換 ''' if 'https://' in text_line or 'http://' in text_line: return '<a href="' + text_line + '" target="_blank" rel="noopener noreferrer">' + text_line + '</a>' else: return text_line

register.filterメソッドは引数nameを省略すると関数の名前がフィルター名として適用されます。今回はcomment_filterですね。前回のテンプレートタグと同様にthreadfiltersをロードしてcomment_filterを適用することにします。

templates/thread/detail_topic.html



{% extends 'base/base.html' %}
{% block title %}トピック作成 - {{ block.super }}{% endblock %}
{% block content %}
{% load threadfilters %}
<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>{{comment.message | comment_filter}}</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 %}

このようになります。{% load threadfilters %}でフィルターを読み込んでいます。{{comment.message | comment_filter}}の部分でコンテキストで渡された文字列を変換しています。ではブラウザで確認してみましょう。

URLを含んだ投稿をします。(今回のケースではURL行は改行される必要があります。)

変換された結果

あれ?ちょっとおかしな結果になりましたね。原因を考えていきましょう。

Djangoテンプレートエスケープ処理

確かに文字列は変換されていますが、HTMLタグがそのまま表示されてしまいました。これはどういうことでしょうか?実はDjangoテンプレートはデフォルトでエスケープ機能がONになっていて、コンテキストで渡された文字列はエスケープ処理が施されて出力されることになっています。PHPでいうとhtmlspecialchars関数が適用されたような状態です。これにより万が一コンテキストに悪意のあるスクリプトが渡されたとしても実行を防ぐ作用をしています。(ただし過信は禁物です。)

今回のようにフィルターでHTMLを生成する場合にはエスケープ機能をOFFにする必要があります。しかし、この機能を外す際にはプログラマーは十分に注意する必要があります。今回はbleachライブラリを使用してエスケープ処理をした後にHTML変換を書けることで対応してみます。

まずはbleachをインストールしましょう。


(venv)$ pip install bleach

次にthread/templatetags/threadfilters.pyを下記のように書き換えます。
thread/templatetags/threadfilters.py


from django.template import Library

import bleach

register = Library()

@register.filter
def comment_filter(text):
    return '
'.join(list(map(convert_url, bleach.clean(text).split('\n')))) def convert_url(text_line): ''' URLリンク行をaタグ付きの行に変換 ''' if 'https://' in text_line or 'http://' in text_line: return '$lt;a href="' + text_line + '" target="_blank" rel="noopener noreferrer">' + text_line + '</a>' else: return text_line

先で説明した通り、テンプレートの機能でエスケープ処理が行われないために、関数内部でエスケープ処理をしています。ではテンプレートを修正しましょう。

templates/thread/detail_topic.html(一部抜粋)



- <p>{{comment.message | comment_filter}}</p>
+ <p>{{comment.message | comment_filter | safe}}</p>

このようにsafeフィルターを付けることでテンプレートのエスケープ処理が外れます。これはプログラマが’safe’な文字列であることを保証するという意味です。その意味を考えたら気軽には使えませんよね。今回はbleach関数で手軽に事前エスケープで対応しましたが、実際はもっと気を配るケースが多いと思います。

ではブラウザでどのように表示されるかを確認してみましょう。URLリンクが想定通り出力されたでしょうか?

最後に

今回はフィルターのカスタマイズとDjangoテンプレートのエスケープ機能を見てきました。ネットで調べ物をしているとセキュリティに無頓着でユーザーの入力によってスクリプトが実行できてしまうような実装をしているサンプルも目にします。よく考えずに参考にすると痛い目を見ると思います。当然、この記事も鵜呑みにせずに使用する際には吟味しないとダメですよ。次回はjavascriptでAjax通信をすることを考えていきます。

Sponsored Link


2-2. テンプレートタグを使ってサイドバーを作成する

今回のテーマは「テンプレートタグを使ってサイドバーを作成する」です。第一章から宿題となっていたサイドバーですね。いつまでもdummyのままでは格好悪いのでサイドバーにカテゴリーが表示されるようにしていきましょう。

※本ページはデバッグツールバーの導入まで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


テンプレートタグって何

これまで説明は省いてきましたが組み込みのテンプレートタグを使用してきました。例えば、{% extends %}や{% url %}などです。テンプレートタグは{% テンプレートタグ名 %}という使い方でテンプレート上で呼び出して使います。このようにテンプレート上で予め決められたルールでレンダリングしてくれるものをテンプレートタグと言います。今回はこのテンプレートタグを自作していきます。

何故必要なのか?

今回はサイドバーに表示されるカテゴリー表示について考えてみましょう。カテゴリーは動的に管理ページで追加や変更、削除することができます。そしてサイドバーは様々なページで表示されます。テンプレートタグを使わずに動的にカテゴリーを表示しようと思うとサイドバーが表示されるページには全てにビューからコンテキストを渡さなければいけなくなります。非常に効率が悪いですよね?そこでテンプレートタグを使用します。

カテゴリーを表示するテンプレートタグを作成する

まずテンプレートタグ用のテンプレートを用意します。

tempates/thread/tags/category_teg.html



<div class="ui relaxed list small divided link">
    {% for category in category_list %}
    <a class="item">{{category.name}}({{category.count}})</a>
    {% endfor %}
</div>

次にテンプレートタグを表示するロジック部分を作成します。thread/templatetagsディレクトリを作成し、その中にthreadtags.pyを作成します。

thread/templatetags/threadtags.py


from django.template import Library
from django.db.models import Count
from ..models import Category

register = Library()

@register.inclusion_tag('thread/tags/category_tag.html')
def categorytag():
    ctx = {}
    ctx['category_list'] = Category.objects.annotate(
            count=Count('topic')).order_by('sort')
    return ctx

これはinclusiotn_tag関数のデコレータを使ってcategorytag関数をテンプレートタグとして登録しています。categorytag関数は単純にデータベースからクエリセットの評価によって得られたCategoryオブジェクトの辞書をコンテキストとして返す関数です。

このクエリセットですが、annotateを用いて各カテゴリーに属するトピックの数を数えて’count’という名前をつけて情報を付与しています。

このコンテキストがcategory_tag.htmlテンプレートに当てられます。では、登録したcategorytagを使ってみましょう。

templates/base/sidebar.htmlを書き換えます。
templates/base/sidebar.html



{% load threadtags %}
<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>
            {% categorytag %}
        </div>
    </div>
</div>

このようにcategorytagをロードして必要な場面で使うことで必ずしもビューからテンプレートにコンテキストを渡さなくても動的なページを作れることが分かりました。今回はinclusion_tagを用いましたがその他にもsimple_tag関数などが用意されており、引数を取る処理も書けます。公式ドキュメントの独自のテンプレートタグとフィルタを参考にして下さい。

では確認してみましょう。localhost:8080にブラウザでアクセスします。

データベースに登録してあるカテゴリーとトピック数が表示されましたね。便利な機能ですので積極的に使っていきましょう。

最後に

今回はDjangoのテンプレートタグをサイドバーで使うという内容で扱いました。必ずしもビューから渡す必要のない動的な処理はテンプレートタグを使用することで実現できる場合もあります。覚えておくと便利ですよ。次回はテンプレートタグと少し似ているフィルターについて扱っていきます。

Sponsored Link


2-1. デバッグツールバーの導入

今回のテーマは「デバッグツールバーの導入」です。第二章がスタートしました。今回は開発に便利なツールを導入することにします。開発中にテンプレートに渡されている変数や、リクエストで受けた値を確認したいことは多いと思います。今回導入するdjango-debug-toolbarはサードパーティ製ライブラリですが、非常に使いやすいですよ。

※本ページはコメント投稿機能を付与するまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


ツールバーの導入

まずはpipでdjango-debug-toolbarを導入しましょう。


(venv)$ pip install django-debug-toolbar

次にmysite/settings.pyの設定に入ります。

デバッグツールバー表示のための設定

mysite/settings.py(一部抜粋)


  DEBUG = True # Trueでないとデバッグツールは表示されない。
  INSTALLED_APPS = [
      'django.contrib.admin',
      'django.contrib.auth',
      'django.contrib.contenttypes',
      'django.contrib.sessions',
      'django.contrib.messages',
      'django.contrib.staticfiles',
+     'debug_toolbar',
      'base',
      'thread',
  ]
  
+ INTERNAL_IPS = [
+     '127.0.0.1',
+ ]
  
  MIDDLEWARE = [
      'django.middleware.security.SecurityMiddleware',
      'django.contrib.sessions.middleware.SessionMiddleware',
      'django.middleware.common.CommonMiddleware',
      'django.middleware.csrf.CsrfViewMiddleware',
      'django.contrib.auth.middleware.AuthenticationMiddleware',
      'django.contrib.messages.middleware.MessageMiddleware',
      'django.middleware.clickjacking.XFrameOptionsMiddleware',
+     'debug_toolbar.middleware.DebugToolbarMiddleware', # Deubg tool bar
  ]

今回はローカル環境内で確認することを前提としてINTERNAL_IPSのIPにはローカルのIPを当てています。もし、開発用サーバーを別に立てる場合はアクセスする側のIPを設定してください。

そしてmysite/urls.pyにも変更を加えます。

mysite/urls.py


  from django.contrib import admin, auth
  from django.urls import path, include
+ from django.conf import settings
  
  urlpatterns = [
      path('admin/', admin.site.urls),
      path('accounts/', include('django.contrib.auth.urls')),
      path('', include('base.urls')),
      path('thread/', include('thread.urls')),
  ]
  
+ if settings.DEBUG:
+     import debug_toolbar
+     urlpatterns = [
+         path('__debug__/', include(debug_toolbar.urls)),
+     ] + urlpatterns

これで設定は完了です。

localhost:8080にアクセスしてブラウザーで確認してみましょう。(ブラウザでの確認方法はプロジェクトの作成をご確認下さい)

‘D,DT’をクリックする。

デバッグツールで分かること

デバッグツールは様々な情報を表示してくれる大変便利なツールです。よく使う機能としてはPOSTやGETで送信された情報の確認やテンプレートに渡された情報の確認だと思います。いくつかユーズケースを紹介します。

テンプレートに渡されたコンテキストを確認する

まずテンプレートに渡したコンテキストの中身を確認したい場合ですが、デバッグツールバーの’Templates’の項を確認します。各テンプレートのToggle contextを開けるとコンテキストが確認出来ます。

リクエスト内容を確認する

GETやPOSTでリクエストされた内容、或いはセッションの内容を確認したい場合は’Request’の項を確認します。

発行されたSQLを確認する

クエリセットが評価された後に発行されるSQL文を確認する際にもデバッグツールは役に立ちます。’SQL’の項を開くとSQLコマンドに関する情報を閲覧することが出来ます。クエリセットの評価結果が予想外の結果を返す場合などに発行されているSQLを確認することはとても重要ですので、活用して下さい。

最後に

本当は一章の先頭で紹介しようかと思っていたのですが、Djangoを機能を紹介する本章の内容としました。次回はテンプレートタグを使ってサイドバーのカテゴリー部分を作成していきます。

Sponsored Link


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-19. カテゴリー毎のトピック一覧画面を作る

今回のテーマは「カテゴリー毎のトピック一覧画面を作る」です。ここでは簡単なDjnagoのORM(Object Relational Mapper)の使い方を確認しておきましょう。

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


データベースからのデータ取得

Djangoではクエリセットというイテレーション可能なオブジェクトを生成して、それを評価することでデータベースと情報をやり取りします。プログラマは直接SQLを書かずともクエリセットを生成する処理を行うことで、内部的にSQLが発行されてデータが処理されます。クエリセットに関してはQuerySet APIが用意されており、プログラマはモデルが持つマネージャを介してクエリセットを操作します。

参考:Django 1.0のAPIリファレンス:情報が古いのですが日本語なので理解しやすいかも知れませんので、記載しておきます。

既にトップページでトピック一覧を作っているのでデータベースからのデータの取得については扱っていますが、以前はorder_by関数で並べ替えをしただけでしたので、ここで基本的な操作を少し見てみます。全てのAPIに関してはAPIリファレンスを見ていただくとして、ここではよく使いそうなものをピックアップします。

クエリセットを生成し返す関数

all():全てのオブジェクトが入ったクエリセットを返す。
filter(**kwargs):指定の照合パラメータに一致するオブジェクトの入った新たなクエリセット返す。
exclude(**kwargs):filterのNOT検索版
order_by(*fields):指定パラメータで昇順に並び替える。’-‘をつけると降順に並び替え
anotate(*args, **kwargs):検索結果に付帯情報をもたせる時に使用する。集計結果やサブクエリ使用時に使う。

クエリセットを生成する関数はドットでつないで記載することが出来ます。その場合はAND条件で結合されます。

クエリセット以外を返す関数

get(**kwargs):照合パラメータに合致するオブジェクトを返す
first():クエリセットの最初のオブジェクトを返す
last():クエリセットの最後のオブジェクトを返す
count():クエリセットのオブジェクトの個数を返す

カテゴリー毎のリスト表示テンプレート作成

前置きが長くなりました。まずはテンプレートを作成します。

templates/thread/category.html



{% extends 'base/base.html' %}
{% block title %}{{category.name}} - {{ block.super }}{% endblock %}
{% block content %}
<div class="ui grid stackable">
    <div class="eleven wide column">
        <div class="ui breadcrumb">
            <a class="section">TOP</a>
            <a class="active section">{{category.name}}</a>
        </div>
        <div class="ui segment">
            <div class="content">
                <div class="header"><h3>{{category.name}}</h3></div>
                <div class="ui divided items">
                    {% if topic_list %}
                    {% for topic in topic_list %}
                    <div class="item">
                        <div class="content">
                            <div class="header">
                                <a href="{% url 'thread:topic' pk=topic.id %}"><h4>{{topic.title}}</h4></a>
                            </div>
                            <div class="meta">
                                <span class="name">{{topic.user_name}}</span>
                                <span class="date">{{topic.created}}</span>
                            </div>
                        </div>
                    </div>
                    {% endfor %}
                    {% else %}
                    <div class="ui warning message">トピックが存在しません</div>
                    {% endif %}
                </div>
            </div>
        </div>
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

もう特別解説は必要ないと思います。賢明な読者はすでに渡すパラメータがどのようなものか想像ついていると思います。次にビューを作ります。今回はリスト表示ということでListViewを継承したクラスビューを作ります。

thread/views.py(一部抜粋)


from django.views.generic import (
        CreateView, FormView, DetailView, TemplateView, ListView)

class CategoryView(ListView):
    template_name = 'thread/category.html'
    context_object_name = 'topic_list'

    def get_queryset(self):
        return Topic.objects.filter(category__url_code = self.kwargs['url_code'])
     
    def get_context_data(self, **kwargs):
        ctx = super().get_context_data(**kwargs)
        ctx['category'] = get_object_or_404(Category, url_code=self.kwargs['url_code'])
        return ctx

さて、ListViewについては良いと思います。今回はmodelパラメータがないですね。ListViewはmodelもしくはquerysetが必要です。今回はURLパラメータとして’url_code’を受ける必要があったのでget_queryset関数ないでquerysetを規定しました。肝心の中身ですが、前述のfilter関数が使われていますね。ここでトピックが持つカテゴリーの’url_code’を検索条件にしているところがキーポイントです。トピックのプロパティなら話が簡単です。id=3やtitle=’hogehoge’などで指定すれば良いのです。しかし今回は一度Categoryオブジェクトを取得するサブクエリを使いたくなるような場面ですよね。Djangoではモデルを跨ぐ検索も’__’でつなぐことで照合条件に指定できます。初心者が意外に躓くポイントなので解説しておきます。

また、get_context_data関数内ではget_object_or_404を使っています。以前に使っているので詳細は省きますがこれは存在しないurl_codeが指定された場合の対策です。

最後にURLを規定しましょう。thread/urls.pyは以下のようになります。

thread/urls.py(一部抜粋)


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

URLのパラメータとして今回は文字列を受けますのでstrで型を指定しています。では確認してみましょう。localhost:8080/thread/category/web_app/にアクセスしてみます。

今度はトピックが存在しないカテゴリを表示してみましょう。localhost:8080/thread/category/os/にアクセスしてみます。

本題とは外れますが、トピック詳細ページのパンくず(breadcrumbs)のURLをカテゴリーページのURLに直しておきましょう。

templates/thread/detail_topic.html(一部抜粋)



 <div class="ui breadcrumb">
     <a href="{% url 'base:top' %}" class="section">TOP</a>
     <i class="right angle icon divider"></i>
-    <a class="section">{{topic.category.name}}</a>
+    <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>

最後に

クエリセットの作成は慣れが必要だと思います。今回の例だけでは練習不足だと思いますので、出会う度にAPIリファレンスを読み想定する挙動をするクエリセットが作成できるように練習しましょう。次回で一章は終了予定です。トピック詳細画面にコメント表示と登録機能を付与していきます。

Sponsored Link


1-18. 確認画面付きのトピック作成画面を作る

今回のテーマは「確認画面付きのトピック作成画面を作る」です。確認画面つき画面とはユーザーの入力した内容を一度表示して必要に応じて入力画面に戻ることができる画面のことです。アンケートページや申込みページでは頻繁に使われますね。これまで使ってきたCreateViewに少し手を加えるだけで簡単に確認画面つきの登録画面が作れますよ。

はじめにお断りしておくと、確認画面の作り方は色々な手法があって、今回ご紹介する方法はあくまで1つの例と捉えていただければと思います。データ保持にセッションを使ったり、URLをページ毎に分ける場合もありますし、ページ遷移はフロントのみ(バックエンドはAPIのみ担う)で対応するなど、ケースごとに対応が異なります。(これは確認画面に限った話ではないですが)

※本ページはFormViewとCreateViewを使うまで読まれた方を対象としています。そのためサンプルソースコードが省略されている場合があります。


確認事項

まず、現状を確認しましょう。ここまで説明のためにビューに色々なクラスを作成してきたので、少し整理します。まず、ビューですが、thread/views.pyにはTopicCreateViewクラスが以下のように書かれている筈です。

thread/views.py(一部抜粋)


class TopicCreateView(CreateView):
    template_name = 'thread/create_topic.html'
    form_class = TopicModelForm
    model = Topic
    success_url = reverse_lazy('base:top')

そしてthread/urls.pyはlocalhost:8080/create_topic/にアクセスされた場合にトピック作成画面を表示するように以下の様になっています。
thread/urls.py(一部抜粋)


urlpatterns = [
    path('create_topic/', views.TopicCreateView.as_view(), name='create_topic'),
    # path('create_topic/', views.topic_create, name='create_topic'),
    path('<int:pk>/', views.TopicTemplateView.as_view(), name='topic'),
]

登録が成功した場合にはbaseアプリケーションのTOPが表示され、登録されたトピックが一番上に表示されるようになっていますね。

確認画面テンプレートの作成

では確認用の画面を作るわけですからテンプレートを追加しましょう。今回はtemplates/thread/confirm_topic.htmlと名付けましょう。

templates/thread/confirm_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 class="active section">トピック作成</a>
        </div>
        <div class="ui segment">
            <div class="content">
                <div class="header"><h3>トピック作成</h3></div>
                <p>内容を確認してください</p>
                <table  class="ui celled table table table-hover" >
                    <tr><td>タイトル</td><td>{{form.title.value}}</td></tr>
                    <tr><td>お名前</td><td>{{form.user_name.value}}</td></tr>
                    <tr><td>カテゴリー</td><td>{{form.cleaned_data.category}}</td></tr>
                    <tr><td>本文</td><td><pre>{{form.message.value}}</pre></td></tr>
                </table>
                <form class="ui form" action="{% url 'thread:create_topic' %}" method="POST">
                    {% csrf_token %}
                    {% for field in form %}
                        {{field.as_hidden}}
                    {% endfor %}
                    <button class="ui button grey" type="submit" name="next" value="back">戻る</button>
                    <button class="ui button orange" type="submit" name="next" value="create">作成</button>
                </form>
            </div>
        </div>
    </div>
    {% include 'base/sidebar.html' %}
</div>
{% endblock %}

注目点は3つあります。まず、カテゴリーのところだけ書き方が変わっていますね。form.category.valueにはカテゴリーIDの数値しか入っておらず、確認画面で表示してもユーザーには分かりません。そこでform_valid関数通過後に生成される検証済みのデータ(cleaned_data)からカテゴリーを取得して表示しています。

二つ目は{{field.as_hidden}}ですね。これは{{form.as_p}}と同様にHTMLを返す関数で、インプットタグのタイプをhiddenとしてくれます。確認画面を作る場合セッションにデータを保持する方法もありますが、今回はhiddenのインプットタグで再度POSTする方式を取ります。

3つ目ですが、戻るボタンと作成ボタンのname属性とvalue属性ですね。つまりPOSTする度にnextパラメータに次にどちらに進むかを司令を出すというわけです。

次に既に作成済みのtemplates/thread/create_topic.htmlにも手を加えましょう。
templates/thread/create_topic.html(一部抜粋)



- <button type="submit" class="ui button">作成</button>
+ <button type="submit" class="ui button" name="next" value="confirm">作成</button>

先程と同様にname属性とvalue属性を追加しました。つまり確認画面に進めという指示を送るということです。

ビューの作成

ではビューの作成に入っていきましょう。既に作成済みのTopicCreateViewを改良していきます。TopicCreateViewはDjagnoのクラスベースビューであるCreateViewを継承したクラスです。

thread/views.py(一部抜粋)


class TopicCreateView(CreateView):
    template_name = 'thread/create_topic.html'
    form_class = TopicModelForm
    model = Topic
    success_url = reverse_lazy('base:top')

    def form_valid(self, form):
        ctx = {'form': form}
        if self.request.POST.get('next', '') == 'confirm':
            return render(self.request, 'thread/confirm_topic.html', ctx)
        if self.request.POST.get('next', '') == 'back':
            return render(self.request, 'thread/create_topic.html', ctx)
        if self.request.POST.get('next', '') == 'create':
            return super().form_valid(form)
        else:
            # 正常動作ではここは通らない。エラーページへの遷移でも良い
            return redirect(reverse_lazy('base:top'))

ソースコードを見たままなのですが、基本的に手を加えたのはform_valid関数のオーバーライドのみです。POSTされたnext値によって場合わけして表示するテンプレートを分けています。’confirm’の場合は先程作成したconfirm_topic.htmlを表示しています。’back’であった場合には受けたformをそのまま渡して入力画面であるtopic_create.htmlを表示しています。これにより、ユーザーの入力値は保持されたまま表示されます。’create’であった場合にはCreateViewのform_valid関数を呼び出して保存処理を行いsuccess_urlに遷移します。これらどれでもない場合は異常系動作となりますが、今回はトップページに飛ばしました。エラーページなどを作成してそちらに飛ばしても良いと思います。

確認してみましょう。
入力画面

確認画面

登録後

最後に

データ検証をフォームが担ってくれているので、基本的にはビューの仕事は見せるべきデータを揃えてテンプレート渡すだけ、という分かりやすい例かなと考えています。一章もそろそろ大詰めですね。次回はカテゴリ毎にトピック一覧表示するページをを作りましょう。

Sponsored Link