Djangoチュートリアルを適当に進める~その4~

2019年1月10日

さて、続き。前回はこれ。
Djangoチュートリアルを適当に進める~その3~
今回は公式チュートリアルだと以下の部分。
https://docs.djangoproject.com/en/2.1/intro/tutorial04/
引き続きWEB投票アプリを作っていく。フォームを使う。

シンプルなフォームを作る

detailのテンプレート(polls/detail.html)を更新する。
<form>を使っていく。


<h1>{{ question.question_text }}</h1>

{% if error_message %}<p><strong>{{ error_message }}</strong></p>{% endif %}

<form action="{% url 'polls:vote' question.id %}" method="post">

{% csrf_token %}
{% for choice in question.choice_set.all %}
    <input type="radio" name="choice" id="choice{{ forloop.counter }}" value="{{ choice.id }}">
    <label for="choice{{ forloop.counter }}">{{ choice.choice_text }}</label><br>
{% endfor %}
<input type="submit" value="Vote">
</form>

上記のテンプレートはラジオボタンで質問の選択肢を表示する。
ラジオボタンの値(value)はそれぞれの質問の選択肢のIDを使っている。
また、ラジオボタンのnameはchoiceとすることで、choice=#(#はIDが入る)といったようにデータが送信される。
フォームのアクション(action)には、データの送信先としてURI(実行するプログラムのこと)を設定する必要があるが、ここでは {% url ‘polls:vote’ question.id %}と設定している。そしてmethod=”post”としている。getではない。データをサーバー側で変更する場合はpostを使う。これはDjangoに限らずWEBアプリを作るときは定石である。
forloop.counterはfor文が何回繰り返されたかを示す。ちなみに、ここでlabel forを使うことで、ラベルの文をクリックすることでもラジオボタンを選択することが出来る。
POSTフォームを利用するとき、常に意識しなくてはいけないのがWEB攻撃の一種である「クロスサイトリクエストフォージェリ」への対策だ。
Djangoを使うときは、この対策について深くはやむ必要はない。
{% csrf_token %}を使うことでその対策が出来る。

さて、送信されたデータを処理するviewを作っていく。
まずはURLconfを確認。

path('/vote/', views.vote, name='vote'),

今はダミーのページを設定している。
以下の文をpolls/views.pyに追加する。

# HttpResponseRedirectが追加されている
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
# reverseのimportが追加されている
from django.urls import reverse

from .models import Choice, Question
# ...
def vote(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    try:
	# formで送信されてきたchoiceのIDを使い、questionに紐づけられた選択肢の中から改めて探す
        selected_choice = question.choice_set.get(pk=request.POST['choice'])
    except (KeyError, Choice.DoesNotExist):
        # もし該当の選択肢が無ければもう一度detailのページを表示。ついでにエラーメッセージも渡す
        return render(request, 'polls/detail.html', {
            'question': question,
            'error_message': "You didn't select a choice.",
        })
	# else文はtryで例外が出なかったときに実行される
    else:
		# 投票数が増えたということでchoiceのvotesに1を足す
        selected_choice.votes += 1
		# しっかり保存
        selected_choice.save()
        # POSTされたデータの扱いに成功したら、HttpResponseRedirectを実行する。ブラウザのバックボタンで二重にデータが送信されるのを防ぐ。
        return HttpResponseRedirect(reverse('polls:results', args=(question.id,)))

このチュートリアルでまだカバーしてないことがいくつかあるので説明する。
request.POSTは送信されてきたデータを扱う辞書型のオブジェクト。本文で出てきたrequest.POST[‘choice’]にはIDが文字として入っている。request.POSTに含まれている値は常に文字列である。ちなみにDjangoはrequest.GETも用意している。
もし、POSTされたデータにchoiceがなければrequest.POST[‘choice’]を実行するとKeyErrorというエラーが生じる。
上記のコードではKeyErrorを検知し、エラーが生じた場合はメッセージを画面に表示するようになっている。
また、選択肢(choice)の投票数をカウントした後、プログラムはHttpResponseではなくHttpResponseRedirectを返している。HttpResponseRedirectは一つのURLの引数を取り、ユーザーをリダイレクトさせる。POSTされたテータがきちんと処理されたら、HttpResponseRedirectを返すのは、これまた定石である。
HttpResponseRedirectと一緒にreverse()を使っているが、これはviewの中でURLをハードコードしないためである。reverseにはURLパターンと、その可変部分を渡す。
今回のプロジェクトであればこんな感じのURLが呼ばれる。

'/polls/3/results/'

上記の「3」の部分はquestion.idが入る。リダイレクトされたURLはresultsのviewを表示する。

さて、リダイレクト先のresultsのviewを書いていこう。
polls/views.pyを以下のように変更する。

from django.shortcuts import get_object_or_404, render

def results(request, question_id):
    question = get_object_or_404(Question, pk=question_id)
    return render(request, 'polls/results.html', {'question': question})

これはほぼdetail()と同じだ。違うのはテンプレートの名前だけだ。この部分は後でまた触れる。

さて、polls/results.htmlを作成する。

<h1>{{ question.question_text }}</h1>

<ul>
{% for choice in question.choice_set.all %}
    <li>{{ choice.choice_text }} -- {{ choice.votes }} vote{{ choice.votes|pluralize }}</li>
{% endfor %}
</ul>

<a href="{% url 'polls:detail' question.id %}">Vote again?</a>

さて、ここでまたテストサーバーを起動して/polls/1/にアクセスして投票してみる。投票の度に結果のページ(results page)が更新するのが確認できるだろう。もし、選択肢を選ばずにフォームを送信した場合はエラーメッセージが表示されるだろう。

投票画面はこんな感じ

投票後はそれぞれの選択肢の得票数が表示される

Note:
このvote()viewには小さな問題がある。それは、二人のユーザーが全く同じときに投票を行うと間違った投票数の結果となるというもの。たとえば投票数の値が42だったとして、そこで二人のユーザーが全く同じ時間に投票したと仕様。この場合、44が望ましい値だが、43が投票数として保存されてしまう。
これは競合状態(race condition)と呼ばれる。これの解決のためには次のリンク先を参照してほしい。
https://docs.djangoproject.com/en/2.1/ref/models/expressions/#avoiding-race-conditions-using-f

generic viewsを使うこと:コードは少ないほうがいい

detail()とresults()のviewは非常にシンプルだ。あと、index()も投票のリストを表示するだけで前の二つに似ている。
これらのviewは良くあるパターンだ。URLから渡されたパラメーターにより、データベースからデータを引っ張って来て、テンプレートを読み込み表示するといったものだ。
Djangoはここでもショートカットを用意している。generic viewsシステムだ。

投票アプリをgeneric viewsシステムを使って書き換えてみよう。そうすれば、今まで書いてきたコードをいくらか削除することが出来る。
さて、generic viewsシステムを利用するにあたって行うことは以下の三つ。

1. URLconfを変更する。
2. 必要のないviewのコードを削除する。
3. generic viewsをベースにした新しいviewを導入する。

なんでここでコードを書き換えなあかんねん!との問いに応える

Djangoアプリを作るときはgeneric viewsを使うべきかを最初に判断することだろう。今回のように、途中から「generic viewsを使おう」なんてことはしないだろう。
しかし、これはチュートリアルであり、ここまではDjangoのコンセプトを知ってもらうためにあえて険しい道を歩んできた。
計算機を使う前に算数の基礎について知っておくのと同じだ(と、ここでチュートリアルの作者のどや顔が思い浮かぶ)。

URLconfを修正する

まずはpolls/urls.pyを開いて以下のように修正する。

from django.urls import path

from . import views

app_name = 'polls'
urlpatterns = [
    path('', views.IndexView.as_view(), name='index'),
    path('<int:pk>/', views.DetailView.as_view(), name='detail'),
    path('<int:pk>/results/', views.ResultsView.as_view(), name='results'),
    path('<int:question_id>/vote/', views.vote, name='vote'),
]

二つ目と三つめのパターンで、<question_id> から <pk> に変わっていることに注意。

viewを変更する

次に古いindex、detail、resultsのviewを削除して、Djangoのgeneric viewsを導入する。
polls/views.pyを次のように変更する。

from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404, render
from django.urls import reverse
# あらたにgenericをimportしておる
from django.views import generic

from .models import Choice, Question


class IndexView(generic.ListView):
    template_name = 'polls/index.html'
    context_object_name = 'latest_question_list'

    def get_queryset(self):
        # 最後の5つの質問を返す
        return Question.objects.order_by('-pub_date')[:5]


class DetailView(generic.DetailView):
    model = Question
    template_name = 'polls/detail.html'


class ResultsView(generic.DetailView):
    model = Question
    template_name = 'polls/results.html'


def vote(request, question_id):
    ... # ここは変更なし。

ここではgeneric viewsの中で、ListViewとDetailViewを使っている。それぞれ、「オブジェクトのリストを表示」と「オブジェクトの個別の詳細ページを表示」といったコンセプトをもっている。

それぞれのgeneric viewはmodelの指定を必要とする。
DateilViewはprimarykeyを必要とする。この値はURLから取得する必要がある。polls/urls.pyでpkとしているのがそれだ。

DetailViewは<app name>/<model name>_detail.htmlといった名前のテンプレートを呼び出す。
今回のプロジェクトでは”polls/question_detail.html”といった名前のテンプレートが指定される。
テンプレートの名前を指定したいときはtemplate_nameという変数を用意して文字列を代入する。
DetailViewとResultsViewは両方ともgeneric.DetailViewの処理が行われ、template_nameを指定しなければ両方とも同じテンプレートが使用される。ここでは、template_nameを指定し、それぞれ違うものを利用している。
ListViewでもtemplate_nameを指定して、今まで作ったものが使用されるようにしている。

ここまでの部分で、テンプレートはquestionやlatest_question_listといった変数を利用している。
DetailViewではquestionという変数が自動的に利用される。これはDjango modelを使ってQuestionというモデルを定義したからである。
Djangoはテンプレートで使う変数に適切なものを選択することが出来る。しかしながら、ListViewでは、自動的に作られる変数の名前はquestion_listである。これを上書きするためにはcontext_object_nameという属性を使う。これを使って、latest_question_listを指定することで、、テンプレート内の変数をそのまま変更せずに使うことができる。

さて、テストサーバーを起動して試してみる。
次はみんな嫌いなテストの時間。